Init Container Pattern: Database Migrations Before App Startup in Docker Compose
AI generated
FROM
RUN
Docker · Compose · Database
Init Containers for Migrations
A clean startup thanks to a separate, short-lived migration container

Hiding migration logic in the app container's entrypoint works fine for a while and then breaks down uncomfortably under scaling, parallel startups, or failed migrations. A dedicated, short-lived init container with a clear success condition fixes this structurally.

16 min read depends_on condition service_completed_successfully Migration strategy

1. Why migrations in the app entrypoint are problematic

The most obvious approach is to run migrations in the app container's entrypoint script before actually starting the web server. This works reliably for a single instance, but becomes an immediate problem once multiple replicas boot up simultaneously, for instance behind a load balancer or in an orchestration environment with several instances. Each instance then attempts to run the same migration in parallel, which, depending on the migration tool, can lead to locks, race conditions, or inconsistent intermediate states.

A second problem is the mixing of responsibilities: if the migration fails, the app container still starts anyway or hangs in an unclear state, depending on how robustly the entrypoint script was written. Healthchecks on the app container typically only check whether the web server responds, not whether the database is actually at the expected schema version. The result: a container marked 'healthy' that is processing requests against an outdated or half-migrated schema.

2. The init container pattern: separating migration from application

The init container pattern solves both problems by moving the migration into its own dedicated service, which can use the same image as the app but starts with a different command, runs, exits, and then no longer exists. The app container only starts once this migration container has successfully exited with code 0. If the migration fails, the container exits with a non-zero code, and the app container never even starts, preventing an inconsistent state from the outset.

The key building block for this in Docker Compose is the combination of depends_on with the condition condition: service_completed_successfully. Unlike the default condition service_started, which only checks whether a container has been started, service_completed_successfully actively waits for the referenced service to finish its process and return exit code 0. That is exactly the behavior a one-shot job like a migration needs.


services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10

  migrate:
    image: myapp:latest
    command: ["npm", "run", "migrate:up"]
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://postgres@db:5432/myapp

  app:
    image: myapp:latest
    command: ["npm", "start"]
    depends_on:
      migrate:
        condition: service_completed_successfully
    ports:
      - "3000:3000"

3. Handling exit codes correctly

For service_completed_successfully to work, the migration script itself must reliably return the correct exit code. Many migration tools like Flyway, Liquibase, or framework-native migration commands do this correctly by default, but a hand-written shell script must explicitly make sure not to swallow errors. A common mistake is a script with multiple commands where only the last command determines the final exit code, while an earlier failed command is silently ignored.

The line set -euo pipefail at the top of a Bash-based migration script is therefore mandatory: -e aborts the script immediately on a failed command, -u treats unset variables as an error, and -o pipefail ensures a pipeline is considered failed if any stage of the pipe fails, not just the last one. Without these three options, a migration script can contain a silent failure that gets passed through as success.


#!/bin/bash
set -euo pipefail

echo "Waiting for database connection..."
until pg_isready -h db -U postgres; do
  sleep 1
done

echo "Running migrations..."
npm run migrate:up

echo "Migration completed successfully."
exit 0

4. Restart behavior of the migration container

A frequently overlooked misconfiguration is setting restart: always or restart: unless-stopped on the migration service. An init container is meant to run exactly once and then stay stopped, not restart on every exit. Using restart: on-failure with a limited retry count can cushion a temporary issue such as a database connection not being ready yet, without ending up in an infinite loop. For most migration containers, the default value no is nonetheless the right and safest choice, combined with a depends_on on the database using condition: service_healthy, so the container never starts before the database is ready in the first place.

On repeated Compose starts, for instance during local development with frequent docker compose up calls, the migration container runs again every time, which is unproblematic with idempotent migration tools, since already-applied migrations are automatically skipped. It is therefore important to choose a migration tool that maintains its own status table, such as schema_migrations or flyway_schema_history, instead of building error-prone custom idempotency logic by hand.


services:
  migrate:
    image: myapp:latest
    command: ["npm", "run", "migrate:up"]
    restart: "no"    # explicit: runs exactly once, never restarts
    depends_on:
      db:
        condition: service_healthy

5. Multi-stage dependency chains with several conditions

In realistic setups a single condition is rarely enough. The migration container has to wait until the database is reported healthy by its healthcheck (service_healthy), the app container has to wait until the migration finished successfully (service_completed_successfully), and a downstream seed container for test data in turn has to wait until both the migration and the app's base configuration are complete. This chain can be modeled directly in Compose as a directed graph of several depends_on entries with matching conditions, without any external orchestration.

Importantly, Compose immediately fails with an error on cyclic dependencies, which is helpful in practice for catching design mistakes early. A common example: a seed container incorrectly depends on the app, even though it should really just populate the database directly, a sign that responsibilities in the service chain are not cleanly separated.


services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10

  migrate:
    image: myapp:latest
    command: ["npm", "run", "migrate:up"]
    depends_on:
      db:
        condition: service_healthy

  seed:
    image: myapp:latest
    command: ["npm", "run", "seed:dev"]
    profiles: ["dev"]
    depends_on:
      migrate:
        condition: service_completed_successfully

  app:
    image: myapp:latest
    command: ["npm", "start"]
    depends_on:
      migrate:
        condition: service_completed_successfully

6. Rollback strategy for failed migrations

The init container pattern prevents the app from starting against an inconsistent schema, but it does not automatically solve the problem of a failed migration itself. After a failed migration, the database is left in an intermediate state, and the app container never starts, which is safer for production environments than a silent failure, but still means downtime until the issue is fixed. A clear rollback strategy therefore belongs with every migration tool: either automatic rollback migrations that revert the schema to the previous state, or a documented manual intervention procedure.

For production-critical systems it also pays to run the migration container with clear logging that outputs the exact failed migration and the SQL error message on failure, combined with a notification, for instance via the exit code of the pipeline that triggered the deploy. An init container that exits correctly with an error code but whose error message disappears into an unmonitored log unnecessarily delays troubleshooting.

7. Translating this to production orchestration

Outside Docker Compose, for instance in Kubernetes, a native and very similar concept exists in InitContainers: a pod can define a list of init containers that must terminate successfully one after another before the actual application containers start. Anyone who has already established this pattern in Docker Compose for local development can carry it over to a Kubernetes environment almost one-to-one, without having to change the application's underlying architecture.

In CI/CD pipelines outside Kubernetes, for instance a classic deploy over SSH or via a cloud service without container orchestration, the same principle can be expressed as an explicit pipeline step: a job runs the migration in a throwaway container and aborts the pipeline on failure before the actual deploy step even begins. The underlying principle, strictly sequencing migration and app startup with a clear success condition, stays identical across every environment.


# Manually running the migration container outside Compose
docker run --rm \
  --network myapp_default \
  -e DATABASE_URL=postgres://postgres@db:5432/myapp \
  myapp:latest npm run migrate:up

echo "Migration exit code: $?"

8. Selective depends_on: not every service needs to wait for the migration

A common modeling mistake is making every service in a Compose project depend on the migration indiscriminately, including ones that have no connection to the database schema at all, such as a Redis cache, a reverse proxy, or a pure log collector. This unnecessarily slows down the overall startup, since services end up waiting that have no real reason to, and at the same time makes the dependency chain harder to understand for new team members, who can no longer tell from a long depends_on list which dependency is actually necessary.

The clean rule is: only services that directly run database queries against the migrated schema should depend on service_completed_successfully of the migration container. A reverse proxy that only forwards HTTP requests does not need that dependency, a background worker that performs database access does. This deliberate separation keeps the dependency chain lean and makes it immediately visible which services actually depend on the schema state.


services:
  # Does NOT need the migration, pure HTTP forwarding
  reverse-proxy:
    image: nginx:alpine
    depends_on:
      app:
        condition: service_started

  # Needs the migration, since it makes direct DB queries
  app:
    image: myapp:latest
    depends_on:
      migrate:
        condition: service_completed_successfully

  # Does NOT need the migration, has no DB access
  redis-cache:
    image: redis:7-alpine

9. Common mistakes when applying the pattern

Probably the most common mistake is a missing or misconfigured healthcheck for the database itself. Without service_healthy as the condition for the database service, the migration container only waits until the database container has started, not until the database process is actually accepting connections. Especially with Postgres or MySQL, one or two seconds often pass after process start before connections are accepted, which without a healthcheck leads to sporadically failing migrations that suddenly work on a retry, making them hard to reproduce.

A second common mistake is configuring the migration container with the same restart: always as the app container, causing it to restart immediately after every successful run and end up in a restart loop, because some migration tools return a non-zero exit code for already-applied migrations instead of simply doing nothing. A quick look at the migration tool's documentation on how it behaves against an already up-to-date schema saves a lot of debugging time here.

depends_on condition Waits for Typical use Failure behavior
service_started Container start, no healthcheck Loosely coupled services No waiting for readiness
service_healthy Healthcheck reports 'healthy' Database before migration/app Waits until connections are possible
service_completed_successfully Container exit code 0 Migration container before app start App does not start on failure
No depends_on Nothing, starts immediately Independent services Race conditions possible

Mironsoft

Container infrastructure, CI pipelines and deployment automation

Docker setups that hold up across the team and in production?

We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.

Dockerfile Review

Systematically optimizing multi-stage builds, layer caching and image size.

Security Audit

Hardening container isolation, secrets handling and image scanning against real attack surfaces.

CI/CD Integration

Building build pipelines, registries and deployment strategies for reproducible releases.

10. Summary

Init Containers for Migrations: Key Takeaways

Core idea

Migration runs in its own short-lived container instead of hidden in the app entrypoint.

Key mechanism

depends_on with condition: service_completed_successfully enforces waiting for exit code 0.

Exit codes

set -euo pipefail in Bash migration scripts prevents silently swallowed errors.

Restart policy

Migration container uses restart: no, so it does not restart endlessly after success.

11. FAQ: Init Containers for Migrations: Key Takeaways

1What is the advantage of an init container over migrations in the app entrypoint?
The init container prevents parallel migration attempts across multiple app instances and ensures the app container never starts if a migration fails, instead of continuing to run in an inconsistent state.
2How exactly does condition: service_completed_successfully work?
Compose waits until the referenced service's process exits and returns exit code 0. Only then does the dependent service start. On a non-zero exit code, the dependent service does not start.
3Does the migration container have to use the same image as the app?
Not necessarily, but it is often practical, since it guarantees the same dependencies and the same code version for both migration and application. A separate, leaner migration image is also possible.
4What restart policy should the migration container have?
Usually restart: no, since the container is meant to run exactly once and then stay stopped. restart: on-failure with a limited retry count is an alternative for temporary connection issues.
5What happens if the migration runs again on every docker compose up?
With an idempotent migration tool this is unproblematic, since already-applied migrations are automatically skipped. The tool should maintain its own status table such as schema_migrations.
6Why does the migration container need the database's healthcheck as a prerequisite?
Without condition: service_healthy for the database, the migration container only waits for the container to start, not for the database to actually accept connections, which leads to sporadically failing migrations.
7Is set -euo pipefail necessary in every migration script?
Strongly recommended for hand-written shell scripts, since without these options an intermediate failed command can be silently ignored and the script still exits with code 0.
8How does this pattern translate to Kubernetes?
Kubernetes offers a native, very similar concept with InitContainers: multiple init containers must terminate successfully one after another before the application containers in the same pod start.
9What should be done if a migration fails in production?
The app container does not start, which means downtime until the issue is fixed. A clear rollback strategy, either automatic rollback migrations or a documented manual intervention procedure, should be prepared in advance.
10Can a seed container for test data use the same pattern?
Yes, a seed container can depend on the successful migration via depends_on with condition: service_completed_successfully, and should additionally be restricted to development environments via profiles so it never runs in production.