DEV Community

Manu Shukla
Manu Shukla

Posted on • Originally published at ecorpit.com

Docker Compose 5.3 init containers: run setup before your app starts (2026 guide)

Docker Compose 5.3 init containers: run setup before your app starts (2026 guide)

Summary. Docker Compose 5.3.0, released on 2 July 2026, adds native pre_start init containers: short-lived containers that run before a service's main container starts, in declared order, each one running to completion before the next begins. If any step exits with a non-zero code, the service does not start. Compose models them as pre_start lifecycle hooks, a sibling of the post_start and pre_stop hooks that shipped in Compose 2.30.0 back in October 2024. The difference matters: post_start and pre_stop run a command inside the running service container, while each pre_start step runs in its own ephemeral container created after the service container is created but before it starts. This guide covers 4 copy-paste patterns from the official Docker documentation, published 2 July 2026: database migrations, volume permission fixes, chained setup steps, and a direct replacement for the old one-shot service plus depends_on pattern. It also covers when not to reach for init containers, how re-runs are skipped, the current per_replica limitation, and how the model compares to Kubernetes init containers. Docker Compose itself is free and open source; only Docker Desktop needs a paid plan, which for larger companies runs up to $24 per user per month as of April 2026. Everything below works with any Compose 5.3.0 install.

What pre_start init containers are

An init container is a short-lived container that runs before a service's main container starts. Docker Compose 5.3.0 exposes them through a new pre_start key on a service. Each entry is a step. The steps run sequentially, each running to completion before the next begins, and the service container only starts once every step has exited 0.

This is the same idea Kubernetes has shipped for years. Per the Kubernetes documentation, init containers "always run to completion, and each init container must complete successfully before the next one starts", after which the kubelet starts the application containers. Compose now brings that ordered, gated setup phase to a single-host compose.yaml file, without a separate orchestrator.

Use pre_start for work that must finish before the application boots: running database migrations, fixing volume permissions, generating dynamic configuration, or executing any ordered sequence of prerequisites. Docker's Compose documentation is explicit that each pre_start step "runs in its own ephemeral container created after the service container is created but before it is started", which is what separates it from the in-container lifecycle hooks.

How pre_start containers run

Every step in a service's pre_start list follows five rules, taken directly from the official init-containers guide:

It runs in its own ephemeral container, created after the service container is created but before it starts. It inherits the service's image by default, and you set image to override that. It joins the same networks as the service, so it can reach services declared in depends_on. It shares the service's volume mounts, so files written to a shared volume are immediately visible to the service. And it must exit 0 for the next step, and the service itself, to start. A non-zero exit aborts startup for the service and anything that depends on it.

That shared context is the useful part. A migration step can reach the database over the same network the app uses, and a permission-fix step can write to the same named volume the app will mount, because Compose gives the init container the service's networks and mounts rather than a fresh, isolated environment.

pre_start vs the lifecycle hooks it sits beside

The three lifecycle hooks look similar in YAML and behave very differently at runtime. This table is the mental model to keep.

Hook Compose version Where it runs When it runs Blocks the service?
pre_start 5.3.0 (2 Jul 2026) Its own ephemeral container After the container is created, before it starts Yes: a non-zero exit stops the service starting
post_start 2.30.0 (Oct 2024) Inside the running service container After the container has started No: the container is already running
pre_stop 2.30.0 (Oct 2024) Inside the running service container Before the container is stopped No: it runs during shutdown
One-shot service Any version A separate peer service Ordered by depends_on conditions Yes, via condition: service_completed_successfully

If the work must block startup and needs a clean, separate environment, pre_start is the right tool. If the work is an in-container touch-up after the process is already live, such as a permissions fix on a mounted volume, post_start fits. If it is a graceful-drain step before shutdown, that is pre_stop.

Pattern 1: run a database migration before the app starts

The most common use is gating the app on a completed schema migration. The service waits for the database to become healthy through depends_on, then runs the migration in an ephemeral container that reuses the app's own image. The service container only starts once the migration exits 0.

services:
  app:
    image: myapp:latest
    depends_on:
      db:
        condition: service_healthy
    pre_start:
      - command: ["./manage.py", "migrate"]

  db:
    image: postgres:18
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5
Enter fullscreen mode Exit fullscreen mode

Two conditions combine here. condition: service_healthy holds the whole app service, including its pre_start steps, until the db health check passes. Then the migration runs. Because the init container joins the app's network, ./manage.py migrate connects to db over the same hostname the app will use. No migration means no app: if the migration exits non-zero, the app container never starts, and neither does anything that depends on app.

Pattern 2: fix volume permissions for a non-root service

Named volumes are created with root ownership. When a service runs as a non-root user, the process cannot write to that volume until someone adjusts the ownership. A pre_start step handles this by overriding the image and the user for the setup step only.

services:
  app:
    image: myapp:latest
    user: "1000"
    volumes:
      - data:/data
    pre_start:
      - image: busybox
        user: root
        command: ["chown", "-R", "1000:1000", "/data"]

volumes:
  data:
Enter fullscreen mode Exit fullscreen mode

The pre_start step uses a different image, busybox, and runs as root, even though the service itself runs as user 1000. The step shares the data volume mount, so the chown lands on exactly the volume the app is about to use. This is a cleaner answer than running the whole application container as root, and cleaner than baking a root entrypoint script into the image.

Pattern 3: chain multiple setup steps

pre_start steps run in declared order, and the next step only starts once the previous one exits 0. That makes ordered prerequisites straightforward: migrate first, then load seed data, then start the app.

services:
  app:
    image: myapp:latest
    depends_on:
      db:
        condition: service_healthy
    pre_start:
      - command: ["./manage.py", "migrate"]
      - command: ["./manage.py", "loaddata", "fixtures.json"]
Enter fullscreen mode Exit fullscreen mode

Each step runs in its own ephemeral container. If the second step fails, the first step is not rolled back, but app does not start. That is worth designing around: write setup steps to be idempotent, so a retried run after a mid-chain failure does not double-apply anything. Migrations and data loads that are safe to run twice keep the whole chain safe to retry.

Pattern 4: replace the one-shot service pattern

Before pre_start, the common way to express "run X before Y starts" was to model the setup work as a service with restart: "no" and have the main service depend on it with condition: service_completed_successfully:

services:
  migrate:
    image: myapp:latest
    command: ["./manage.py", "migrate"]
    restart: "no"

  app:
    image: myapp:latest
    depends_on:
      migrate:
        condition: service_completed_successfully
Enter fullscreen mode Exit fullscreen mode

The equivalent with pre_start is shorter and reads as part of the service it belongs to:

services:
  app:
    image: myapp:latest
    pre_start:
      - command: ["./manage.py", "migrate"]
Enter fullscreen mode Exit fullscreen mode

Docker's documentation lists four reasons pre_start is preferable for this case. The setup work is modelled as a subordinate step of the service, not as a peer service that exits immediately. Completed steps do not appear as exited services in docker compose ps. Chaining several setup steps does not require a web of depends_on edges between one-shot services. And the ephemeral container inherits the service's image by default, so no duplicate image: declaration is needed.

Dimension pre_start init container One-shot service + depends_on
Where setup lives A subordinate step of the service A separate peer service
Shows in docker compose ps No exited container clutters the list Exited service appears in the list
Image declaration Inherited from the service Repeated on the one-shot service
Chaining several steps A list under one key Multiple services plus depends_on edges
Reusable by many services Not directly; it is service-scoped Yes, several services can depend on it

The one-shot service pattern still has its place. Docker notes it fits when the setup work is a shared concern that multiple services depend on, or when it needs to be addressable independently of any single service. If three services all wait on one seeding job, a single one-shot service they each depends_on is still the cleaner model.

When not to use init containers

Init containers are not the answer to every startup problem, and reaching for them by reflex creates its own mess.

For static files and secrets, use the native configs and secrets top-level elements instead. Compose mounts those directly into containers with a configurable target path, mode, UID, and GID, so no init container is required to place a config file or a credential.

For background tasks with their own lifecycle, such as scheduled backups, post-exit cleanup, or periodic maintenance, init containers are the wrong tool. Those tasks run independently of service startup, not before it, so they belong in a cron container, a sidecar, or a separate scheduled job, not in pre_start.

The rule of thumb: pre_start is for prerequisites that must finish before this specific service boots. If the work is not a precondition of one service's startup, it does not belong there.

Re-runs: what Compose skips and what it repeats

pre_start steps are not blindly re-executed on every docker compose up. A step is skipped on subsequent runs if it previously succeeded, its definition has not changed, or when the service container restarts under its restart policy. It reruns if the definition changes, the previous run failed, or the service is recreated with --force-recreate.

That behaviour is close to what you want for migrations: a successful migration step does not fire again on the next up, so an idempotent-by-default step and Compose's skip logic together keep repeated deploys cheap. When you do need to force a setup step to run again, --force-recreate on the service is the switch.

Limitations to plan around

Two limitations are worth knowing before you lean on pre_start in production.

First, pre_start runs once for the service as a whole, not once per replica. It behaves as per_replica: false, and per-replica execution through per_replica: true is not yet supported in Compose 5.3.0. If you scale a service to several replicas, the setup runs once, not once per instance. Volume mounts shared across replicas, such as named volumes and bind mounts, are reachable from a pre_start step, but per-instance mounts such as tmpfs or anonymous volumes cannot be addressed by a single shared run.

Second, pre_start does not re-trigger when you scale a service up. A step runs again only on a definition change, a prior failure, or --force-recreate. Scaling from two replicas to four does not fire the setup step for the new replicas, so any per-instance initialisation still needs its own mechanism inside the container's entrypoint.

For multi-host, multi-replica orchestration with true per-instance init semantics, this is where a single-host Compose file stops and an orchestrator begins. Teams already running a cluster can compare the trade-offs in our note on the Kubernetes 1.35 and containerd 2.0 migration, and teams splitting a large app into services will hit these ordering questions during a monolith to microservices modernization.

Docker Compose pre_start vs Kubernetes init containers

The models rhyme, but they are not identical. If your team runs Compose locally and Kubernetes in production, knowing where they diverge saves surprises.

Aspect Compose pre_start (5.3.0) Kubernetes init containers
Ordering Sequential, each exits 0 before the next Sequential, each completes before the next
Failure behaviour Non-zero exit aborts service startup Kubernetes restarts the Pod until the init container succeeds
Default image Inherits the service's image Each init container specifies its own image
Scope Once per service (per_replica: false) Once per Pod, on every Pod (re)start
Probes Not applicable Init containers do not support liveness, readiness, or startup probes
Config surface A compose.yaml file A Pod spec

The sharpest practical difference is failure handling. A failed Compose pre_start step stops the service and reports the failure; a failed Kubernetes init container sends the Pod into a restart loop until it succeeds. Migrations that can hang or that depend on an external system behave differently under each, so a step that is safe on a laptop under Compose is not automatically safe under a Kubernetes restart loop.

India-specific considerations

For teams in India building on this pattern, the migration step is often where personal data first moves into a schema. Under the Digital Personal Data Protection Act 2023 (DPDP), the obligations attach to how that personal data is processed and stored, not to the tool that runs the migration. A pre_start migration that creates or alters tables holding personal data is a good checkpoint to confirm the schema encodes your retention and consent fields, because it runs on every fresh environment before the app serves a single request.

Keeping setup deterministic also helps with auditability. When every environment boots through the same ordered, gated pre_start steps, the path from an empty database to a running service is identical in development, staging, and production, which is easier to evidence than a pile of manual setup scripts. Docker Compose is free and open source, so this consistency costs nothing in tooling; the only paid line item is Docker Desktop for larger organisations, billed up to $24 per user per month as of April 2026, and Compose runs the same on a plain Docker Engine install.

FAQ

What Docker Compose version do I need for init containers?

Init containers require Docker Compose 5.3.0 or later, released on 2 July 2026. The feature is exposed through the pre_start key on a service. Earlier versions do not recognise pre_start as an init-container hook, so you must upgrade Compose before the syntax in this guide will run.

How is pre_start different from post_start?

Each pre_start step runs in its own ephemeral container, created after the service container is created but before it starts, and a non-zero exit stops the service. post_start, shipped in Compose 2.30.0, runs a command inside the already-running service container and does not block startup. They solve different problems.

Does a failed init container stop my service?

Yes. Each pre_start step must exit 0 for the next step, and the service itself, to start. A non-zero exit aborts startup for that service and anything that depends on it. Unlike Kubernetes, Compose does not restart the service in a loop; it reports the failure and the service stays down.

Can I run an init container as a different user or image?

Yes. A pre_start step inherits the service's image by default, but you can set image to override it, and set user for that step alone. A common use is running a busybox step as root to fix volume ownership, even when the service runs as a non-root user like 1000.

Do init containers re-run on every docker compose up?

No. A pre_start step is skipped if it previously succeeded and its definition has not changed, or when the container restarts under its restart policy. It reruns only if the definition changes, the previous run failed, or you recreate the service with --force-recreate. Write steps to be idempotent regardless.

Should I still use the one-shot service pattern?

Sometimes. pre_start is cleaner for setup scoped to one service, since it avoids exited services in docker compose ps and needs no duplicate image line. The one-shot service with condition: service_completed_successfully still fits when several services depend on the same setup job or it must be addressable on its own.

Do init containers run once per replica?

No. In Compose 5.3.0, pre_start runs once for the service as a whole, behaving as per_replica: false; per-replica execution is not yet supported. Scaling a service up does not re-trigger the step. Shared named volumes are reachable from the step, but per-instance mounts such as tmpfs cannot be addressed by one shared run.

When should I not use init containers?

Use the configs and secrets top-level elements for static files and credentials, since Compose mounts those directly with a target path, mode, and ownership. Keep background work with its own lifecycle, such as scheduled backups or periodic cleanup, out of pre_start; those run independently of startup, not as a precondition of it.

How eCorpIT can help

eCorpIT is a Gurugram-based, CMMI Level 5 and ISO 27001:2022 certified engineering organisation that ships containerised backends and the deployment pipelines around them. If your docker compose up has grown a tangle of one-shot migration services, wait-for-it scripts, and manual setup steps, our senior engineering teams can refactor it into ordered, gated pre_start init containers and align the same startup contract across development, staging, and production. We also design data-handling steps aligned with DPDP Act 2023 requirements where migrations touch personal data. Tell us about your stack at /contact-us/ and we will map the fastest path from brittle startup scripts to a deterministic boot sequence.

References

  1. Docker Docs, Use init containers in Compose (published 2 July 2026).
  2. Docker Docs, Using lifecycle hooks with Compose (post_start and pre_stop).
  3. Docker Compose GitHub releases, v5.3.0 (2 July 2026, pre_start init containers).
  4. Docker Compose GitHub releases, v2.30.0 (lifecycle hooks, October 2024).
  5. Docker Docs, Compose file reference: pre_start.
  6. Docker Docs, Compose file reference: post_start.
  7. Docker Docs, Compose file reference: depends_on.
  8. Docker Docs, Control startup order in Compose.
  9. Docker Docs, Configs top-level element and Secrets top-level element.
  10. Kubernetes Documentation, Init Containers.
  11. Docker, Subscriptions and pricing (Docker Desktop plans, as of April 2026).
  12. Ministry of Electronics and IT, Digital Personal Data Protection Act 2023.

Last updated: 30 July 2026.

Top comments (0)