Running Migration Jobs Before App Startup
The API starts, receives its first request and fails with relation "orders" does not exist because migrations were still running; or two API replicas both run migrate at startup and one crashes with duplicate key value violates unique constraint "schema_migrations_pkey"; or a failed migration leaves the app running against a half-migrated schema because the entrypoint script ignored the exit code. Migrations are a one-time job that must finish, successfully, before the application starts — and Compose can model exactly that. This page moves migrations into a dedicated one-shot service that the application depends on, as part of multi-service orchestration with Compose.
The same pattern covers other startup jobs: seeding a database, creating buckets, compiling assets.
Diagnostic
Look at how and where migrations currently run, and at the timing of the first failure:
#!/usr/bin/env bash
set -euo pipefail
grep -nE 'migrate|alembic|prisma migrate|flyway' compose.yaml */entrypoint.sh 2>/dev/null || true
docker compose down -v >/dev/null 2>&1
docker compose up -d --scale api=2
sleep 20
docker compose logs --timestamps api | grep -E 'migrat|does not exist|duplicate key' | head -6
docker compose exec -T db psql -U postgres -d shop -c 'select count(*) from schema_migrations' 2>&1 | tail -2
Expected bad output:
api/entrypoint.sh:4:npm run migrate || true
2026-09-18T10:02:01.311Z api-1 | running migrations
2026-09-18T10:02:01.315Z api-2 | running migrations
2026-09-18T10:02:01.902Z api-2 | error: duplicate key value violates unique constraint "schema_migrations_pkey"
2026-09-18T10:02:02.040Z api-1 | GET /orders 500 relation "orders" does not exist
Both replicas migrate concurrently, one fails, || true hides the failure, and requests are served before the schema exists.
Root cause
Running migrations in the application's entrypoint couples two things with different lifecycles. Migrations should run once per deploy and complete before any request is served; application processes run many times, in parallel, and restart independently. Putting migrations in the entrypoint means every replica and every restart runs them, replicas race on the migrations table, and the app starts listening as soon as the entrypoint moves on — which, with || true, happens even after a failure. Compose's depends_on with service_started or service_healthy cannot fix it, because the migration is not a separate service. Compose does have a condition designed for this: service_completed_successfully, which waits for another service's container to exit with code 0. Modelling migrations as a one-shot service turns the ordering and the failure handling into configuration.
The same mistake hides in a subtler form in images whose CMD chains commands with && or ;, such as npm run migrate; node server.js. With a semicolon, the server starts whether or not the migration succeeded. With &&, a failed migration stops the container, but Compose's default restart policy may restart it in a loop, re-running a partially applied migration each time. Both behaviours are easy to miss in development because the database is usually empty and migrations usually succeed; they surface the first time someone pulls a branch with a broken migration and spends an hour wondering why the API is serving 500s. A separate job makes the failure a clear, single event with its own logs and exit code.
Resolution
- Create a one-shot migration service from the same image as the application:
services:
db:
image: postgres:16.4
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: shop
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-d", "shop"]
interval: 3s
retries: 30
migrate:
build: ./api
command: ["npm", "run", "migrate"]
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/shop
depends_on:
db:
condition: service_healthy
restart: "no"
api:
build: ./api
command: ["node", "dist/server.js"]
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/shop
depends_on:
migrate:
condition: service_completed_successfully
restart: "no" keeps the job from looping; service_completed_successfully holds the API until the job exits 0 and blocks it entirely if the job fails.
- Remove migrations from the application entrypoint, including any
|| true:
#!/usr/bin/env bash
set -euo pipefail
sed -i.bak '/migrate/d' api/entrypoint.sh
grep -n migrate api/entrypoint.sh || echo "entrypoint no longer runs migrations"
- Chain other startup jobs the same way. A seed job depends on the migration job; the API depends on the seed job in development:
services:
seed:
build: ./api
command: ["npm", "run", "db:seed"]
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/shop
depends_on:
migrate:
condition: service_completed_successfully
profiles: ["seed"]
Putting seed behind a profile lets developers opt in with docker compose --profile seed up, as described in running a subset of services with Compose profiles.
- Use
up --waitin scripts so a failed migration fails the command:
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --wait || { docker compose logs migrate | tail -20; exit 1; }
Expected output
$ docker compose up -d --wait
[+] Running 4/4
✔ Container shop-db-1 Healthy
✔ Container shop-migrate-1 Exited
✔ Container shop-api-1 Healthy
$ docker compose ps -a --format '{{.Service}}\t{{.State}}\t{{.ExitCode}}'
api running 0
db running 0
migrate exited 0
The migration runs exactly once, the API starts only after it succeeds, and scaling the API to several replicas no longer races on the schema. If a migration fails, up --wait exits non-zero, the API never starts, and the migration logs show why.
Restarting the API with docker compose restart api no longer touches the schema at all, and a developer who wants to re-run migrations after pulling new ones runs docker compose run --rm migrate explicitly. That explicitness is useful beyond correctness: it makes the moment a schema change happens visible in the terminal, rather than buried in application startup logs.
Prevention
Ban migration commands in entrypoints with a CI grep over entrypoint scripts and Dockerfile
CMD/ENTRYPOINTlines.Test a failing migration in CI. A deliberately broken migration in a test branch should make
docker compose up --waitfail; if it does not, a|| trueor a missing condition has crept in.Mirror the pattern in production — a Kubernetes Job, a pre-deploy step in the pipeline — so local and deployed environments share the same ordering, as covered in running schema migrations identically in local and CI.
Platform caveats
Compose version:
service_completed_successfullyrequires Compose v2 (any 2.x release). The legacydocker-composev1 ignores it and starts services immediately.
macOS and WSL2: no platform differences; the job runs inside the Docker VM like any other container.
Apple Silicon (ARM64): the migration job uses the same image as the application, so it runs natively whenever the application does.
Rollback
Restore the entrypoint and remove the job service to return to the previous behaviour:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- compose.yaml api/entrypoint.sh
docker compose up -d --force-recreate
Frequently Asked Questions
How do I make a service wait for another container to finish?
Use depends_on with condition: service_completed_successfully. Compose starts the dependent service only after the other container exits with code 0, and never starts it if the exit code is non-zero.
Why not just run migrations in the entrypoint with a lock?
Advisory locks prevent concurrent runs but still start the application before migrations finish on the other replica, and still tie migrations to every restart. A separate job is simpler and matches how most production platforms run migrations.
Does the migration job run again on every docker compose up?
Yes, and it should be a no-op when nothing is pending. Migration tools track applied migrations in a table, so re-running is fast and safe.
How do I rerun just the migrations?
Run docker compose run --rm migrate. The job container is created, runs the migration command and is removed, without restarting the application.