Tutorial

Docker Compose Adds Init Containers: Say Goodbye to One-Off Initialization Services

2026-07-04 #Docker#Docker Compose#Init Containers

When deploying applications with Docker Compose, have you ever struggled with how to gracefully run database migrations or fix directory permissions before the main application starts?

In the past, we had to write all kinds of one-off services and chain them using complex depends_on rules. Now, with the release of the new Docker Compose features, we finally have official Init Containers support. In this post, we'll dive into how it works and how it can help us slim down our compose.yaml!

When deploying applications with Docker Compose, we often run into a very common requirement: performing some initialization tasks before the main application boots up.

For example:

  • Running database migrations before starting the web service.
  • Fixing mount directory permissions before running the container as a non-root user.
  • Generating configuration files before the application officially launches.
  • Importing initial seed data before launching the main service.

In the past, we usually defined a separate, one-off service like migrate, init, or setup, and controlled the execution order using depends_on with condition: service_completed_successfully.

Now, Docker Compose officially provides a more natural approach: Init Containers.

According to the official Docker documentation, this feature requires Docker Compose 5.3.0 or above.

Init Containers are essentially short-lived containers that execute sequentially before the main service container starts. If any step exits with a non-zero code, the main service will not start.

What Are Init Containers?

You can think of Init Containers as:

A set of initialization steps bound to run before a specific service starts.

They are not long-running services or background tasks, but rather temporary containers that "run and exit".

In Docker Compose, this capability is implemented via the pre_start lifecycle hook. According to the official documentation, each step in pre_start runs in its own independent temporary container, which executes after the service container is created but before it actually starts.

Here is a simple example:

1
2
3
4
5
services:
app:
image: myapp:latest
pre_start:
- command: ["./manage.py", "migrate"]

This configuration means:

  1. Docker Compose first creates the app service container.
  2. Before app actually starts, it runs ./manage.py migrate.
  3. If the migration command exits successfully (exiting with status code 0), app will start.
  4. If the migration fails, app will not start.

This is much cleaner than writing a separate migrate service like we used to.

How Did We Do It Before?

Before pre_start came along, if we wanted to express "run migration first, then start the application", we typically wrote it like this:

1
2
3
4
5
6
7
8
9
10
11
services:
migrate:
image: myapp:latest
command: ["./manage.py", "migrate"]
restart: "no"

app:
image: myapp:latest
depends_on:
migrate:
condition: service_completed_successfully

While this approach works, it has several obvious issues:

  • Conceptual Confusion: migrate is not actually a long-running service; it is just an initialization step for app. Putting it at the top-level of services makes the Compose configuration file bloated.
  • Residual State: Once the task is complete, it remains in the docker compose ps list as an exited service, which litters your active service list.
  • Tedious Orchestration: If you have multiple initialization steps (e.g., migrating the database, seeding default data, and generating configuration files), you have to define multiple one-off services and chain them using a series of complex depends_on rules.

With the new pre_start feature, we can consolidate them directly inside the service:

1
2
3
4
5
6
services:
app:
image: myapp:latest
pre_start:
- command: ["./manage.py", "migrate"]
- command: ["./manage.py", "loaddata", "fixtures.json"]

Official Docker documentation highlights the following benefits of pre_start:

  1. Initialization logic is expressed as a sub-step of the service, rather than masquerading as a parallel, independent service.
  2. Completed temporary steps do not appear in docker compose ps, keeping the environment clean.
  3. Multiple steps are executed sequentially, eliminating the need for complex chain-like depends_on logic.

Example 1: Running Database Migrations Before Start

This is the most classic use case.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
services:
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
pre_start:
- command: ["./manage.py", "migrate"]

db:
image: postgres:18
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: password
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s

There are two key details here:

  1. Dependency: app waits for the db container to reach a service_healthy state via depends_on.
  2. Initialization Timing: Before app starts, it executes ./manage.py migrate inside a temporary container.

If the database migration succeeds, the main app service starts; if it fails, app will not start, and the error logs will output directly within docker compose up.

This design is perfect for Django, Rails, Laravel, Prisma, Drizzle, Alembic, or any other framework that requires database schema migration before running the app.

Example 2: Fixing Volume Permissions

Another major pain point is permissions: when a service runs as a non-root user but is mounted to a Docker named volume (which defaults to root ownership), the container can crash due to lack of write access.

In the past, we had to modify the Dockerfile extensively or spin up a separate sidecar container. Now, pre_start makes it incredibly lightweight:

1
2
3
4
5
6
7
8
9
10
11
12
13
services:
app:
image: myapp:latest
user: "1000:1000"
volumes:
- data:/data
pre_start:
- image: busybox
user: root
command: sh -c 'chown -R 1000:1000 /data'

volumes:
data:

In this example:

  • The app container runs as a non-root user 1000:1000.
  • The pre_start step overrides the image to busybox and executes chown as root to correct the directory ownership before the main service runs.

This scenario is extremely useful in self-hosted and open-source deployments, such as:

  • Fixing upload or cache directory permissions.
  • Automatically initializing the structure of a data directory.
  • Pre-generating default configuration files in a mounted path.

Example 3: Chaining Multiple Initialization Steps

pre_start supports defining a list of steps that will execute sequentially. The next step will run only if the previous one exits successfully (returns 0). If any intermediate step fails, subsequent steps and the main service will terminate.

For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
services:
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
pre_start:
- command: ["./manage.py", "migrate"]
- command: ["./manage.py", "loaddata", "fixtures.json"]
- command: ["./scripts/generate-config.sh"]

db:
image: postgres:18

The entire startup flow is:

  1. Wait for the database container to become healthy.
  2. Execute the database schema migrations.
  3. Import initial seed data.
  4. Generate dynamic configuration files.
  5. Spin up the main application container.

Keep in mind that the steps in pre_start are not transactional. If the second step succeeds and the third one fails, Docker Compose will not roll back the second step.
Therefore, it is highly recommended to design initialization scripts with idempotent logic.

For instance, when inserting seed data:

1
2
3
4
5
6
-- Not recommended: naive insert every time, which might cause primary key conflicts or duplicate data
INSERT INTO users (id, name) VALUES (1, 'admin');

-- Recommended: skip when conflict is detected, ensuring idempotency
INSERT INTO users (id, name) VALUES (1, 'admin')
ON CONFLICT (id) DO NOTHING;

Execution Rules of pre_start Containers

According to the official documentation, the temporary containers spun up by pre_start behave according to these rules:

  • Independent Containers: Each step runs in its own dedicated, short-lived container.
  • Inherited by Default: They inherit the image defined by the host service, so you don't need to specify it repeatedly.
  • Image Override Allowed: You can specify another lightweight utility image (like busybox) via the image field.
  • Shared Network: They automatically join the network of the host service, giving them direct access to other dependent services declared in depends_on.
  • Shared Volume Mounts: They automatically share all volumes declared on the host service.
  • Strict Status Code Constraint: They must return exit code 0 for subsequent steps and the main service to run.

These features make configurations extremely convenient. For instance, if the migration command is already bundled in the application image, you can declare it directly without specifying the image:

1
2
pre_start:
- command: ["pnpm", "db:migrate"]

If you need a helper image (such as busybox) for file operations, just override the image in the step:

1
2
3
4
pre_start:
- image: busybox
user: root
command: sh -c 'chown -R 1000:1000 /data'

Since the temporary containers share the same network and volumes, any configuration file generated, permissions fixed, or data populated in the mount path will be immediately visible to the main container when it starts.

Does It Run Every Time I Execute docker compose up?

The answer is: No. This is a crucial detail to keep in mind.

According to the official Docker documentation:

  • If a pre_start step has already run successfully in a previous deployment and its definition has not changed, Compose will automatically skip it on subsequent runs of docker compose up.
  • If a service restarts due to its restart policy, pre_start will not be triggered again.
  • The initialization steps will only run again if:
    1. The step's configuration definition changes.
    2. The previous execution failed (exited with a non-zero code).
    3. You force recreation using docker compose up --force-recreate.

Thus, pre_start acts more like a "one-time deployment initialization" in the lifecycle of the host service, rather than a recurring process wrapper.

If you have logic that must run on every container restart, scale-up, or process launch (such as reading the latest environment variables), you should still embed it in the image's entrypoint.sh entrypoint script.

When Are Init Containers Not Suitable?

While Init Containers are highly convenient, they are not a silver bullet. According to official guidelines, if your requirement is simply to inject static configuration files or credentials, you should prioritize Docker Compose's native configs and secrets, which support direct mounts and fine-grained permissions/ownership controls.

We do not recommend using pre_start in the following scenarios:

1. Mounting Static Configuration Files

For fixed configuration files that do not need to be dynamically generated, mount them directly instead of spinning up an initialization container to write them.

Recommended Approach:

1
2
3
4
5
6
7
8
9
10
configs:
app_config:
file: ./config/app.yml

services:
app:
image: myapp:latest
configs:
- source: app_config
target: /app/config.yml

2. Sensitive Credential (Secret) Injection

Database passwords, API keys, and other sensitive tokens should never be written to a volume or filesystem using initialization scripts to avoid leakage.

Recommended Approach:

1
2
3
4
5
6
7
8
9
secrets:
db_password:
file: ./secrets/db_password.txt

services:
app:
image: myapp:latest
secrets:
- db_password

3. Cron Jobs and Background Maintenance Tasks

For example:

  • Routine data backups.
  • Periodic cleanup of cache or temporary files.
  • Post-termination cleanup work.

These tasks are not blocker dependencies for the main application start, and therefore should not be put in pre_start.

Current Design Limitations

Since this feature is relatively new, you should keep the following limitations in mind:

  • No Per-Replica Execution: pre_start runs once per service, not per container instance (currently, there is no per_replica: true support).
  • Incompatibility with Certain Mounts: While shared named volumes and bind mounts work perfectly, pre_start cannot share files with main containers if you are using anonymous volumes or tmpfs mounts, which are unique to each container instance.
  • No Trigger on Scaling: When scaling up (e.g., running docker compose up --scale app=3), new container instances do not trigger pre_start again. It still follows the "config changed or forced recreation" triggering logic.

Therefore, if your initialization logic (like local cache pre-warming) must run on every replica, do not rely on pre_start for now.

Differences from Kubernetes Init Containers

Conceptually, both serve the same purpose: preparing the environment before the main workload container starts.

However, they differ in implementation and power:

  • Kubernetes (K8s) Init Containers are first-class, Pod-level entities deeply integrated with cloud-native distributed scheduling, and natively support initializing each replica independently.
  • Docker Compose Init Containers are implemented as a lightweight solution via the pre_start lifecycle hook. It is geared towards single-host deployments, local development, and small-scale self-hosted environments.

Simply put, Docker Compose's Init Containers are a simplified initialization mechanism tailored specifically for Compose workloads—small but highly capable.

Best Practice Recommendations

When using pre_start in production or development environments, we recommend following these principles:

  1. Absolute Idempotency: Your initialization scripts must achieve "identical results over multiple runs" to handle retries and recreations cleanly.
  2. No Persistent Processes: Each step in pre_start must exit quickly with 0. Long-running or daemonized processes will block the main service startup indefinitely.
  3. Separate Production and Development Migration Strategies: While auto-running migrate in pre_start is convenient, running migrations automatically in production carries risk. Consider triggering database migrations via an independent CI/CD pipeline for critical online environments.
  4. Encapsulate Complex Logic in Scripts: If your initialization contains complex checks or multiple setup steps, package them into a dedicated shell script (e.g., ./scripts/init.sh) inside the container and call it from the compose.yaml to keep your configuration clean.
1
2
pre_start:
- command: ["./scripts/init.sh"]

Conclusion

Docker Compose's Init Containers finally address a long-standing pain point in container orchestration: how to gracefully define start-up dependencies.

We can summarize the suitable and unsuitable scenarios as follows:

Suitable ✅ Unsuitable ❌
Automatic database migrations (migrate) Long-running background tasks
Seed data import & database initialization Cron tasks like scheduled backups or logs cleanup
Adjusting host volume mount permissions Static configuration file injection (use configs)
Generating local/dynamic configuration files Sensitive credentials management (use secrets)
Chaining ordered validation steps Initialization logic that must run on each replica

Let's conclude with a side-by-side comparison of the configuration before and after this feature:

1
2
3
4
5
6
7
8
9
10
11
services:
migrate:
image: myapp:latest
command: ["./manage.py", "migrate"]
restart: "no"

app:
image: myapp:latest
depends_on:
migrate:
condition: service_completed_successfully

This is the core value of Docker Compose Init Containers: letting initialization steps gracefully return to where they truly belong.

Comments
Share

Comments