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.

A Migration Race With Two Replicas Timeline of two API replicas racing to run migrations while serving requests. A Migration Race With Two Replicas 01.311 api-1 starts migrating 01.315 api-2 starts migrating 01.902 api-2 fails, ignored 02.040 request hits missing table 02.600 api-1 finishes migrating
Running migrations inside the app lets requests and a second replica overlap with them.

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

  1. 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.

  1. 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"
  1. 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.

  1. Use up --wait in 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; }
Startup Order With a Migration Job Flow from the database becoming healthy through the one-shot migration job to the application starting. Startup Order With a Migration Job db healthy migrate job runs once exit code 0 completed api starts, serves
The application cannot start until the migration container has exited successfully.

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

  1. Ban migration commands in entrypoints with a CI grep over entrypoint scripts and Dockerfile CMD/ENTRYPOINT lines.

  2. Test a failing migration in CI. A deliberately broken migration in a test branch should make docker compose up --wait fail; if it does not, a || true or a missing condition has crept in.

  3. 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.

Entrypoint Migrations vs Migration Job Comparison of running migrations in the application entrypoint against a one-shot Compose service. Entrypoint Migrations vs Migration Job in app entrypoint one-shot migrate service runs in every replica runs exactly once races on schema table no concurrency failure often ignored failure blocks the app requests before schema app waits for exit 0
The job pattern makes ordering and failure handling part of the Compose configuration.

Platform caveats

Compose version: service_completed_successfully requires Compose v2 (any 2.x release). The legacy docker-compose v1 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.