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.
Table of Contents
- 1. Why migrations in the app entrypoint are problematic
- 2. The init container pattern: separating migration from application
- 3. Handling exit codes correctly
- 4. Restart behavior of the migration container
- 5. Multi-stage dependency chains with several conditions
- 6. Rollback strategy for failed migrations
- 7. Translating this to production orchestration
- 8. Selective depends_on: not every service needs to wait for the migration
- 9. Common mistakes when applying the pattern
- 10. Summary
- 11. FAQ
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.