Skip to main content
RunBook Academy

Docker & ContainersIX Β· Docker ComposeDependencies

Dependencies and startup order

Intermediate⏱ ~26 mindocker

What you'll learn

  • Explain exactly what plain `depends_on` waits for, and what it does not
  • Diagnose the cold-boot race that only reproduces on a cold host
  • Use `condition: service_healthy`, `service_completed_successfully`, `restart` and `required`
  • Verify startup ordering with a command whose output can fail

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12

Not yet marked complete on this device.

Service start order is one of the hardest things to get right in Compose, and the reason is that the obvious answer is almost right. The right answer is β€œlet the service declare what it depends on, and use healthchecks to know when the dependency is ready” β€” but the word doing all the work in that sentence is ready, and plain depends_on does not know what it means.

The failure that costs a night

Here is a Compose file that looks correct, passes review, and works every single time you test it:

services:
  api:
    image: myorg/api:1.4.0
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

It works because you have never actually cold-booted it. Every time you ran docker compose up -d on your laptop, the db container already existed with a warm page cache and a Postgres data directory that had already been initialised. Postgres came up in under a second. The API connected on its first attempt.

Then the host reboots, or someone runs docker compose down and brings the stack back on a fresh volume, and this happens:

Read-only / Safelogs
$ docker compose logs api
api-1  | 2026-08-12T04:03:11Z INFO  starting api 1.4.0
api-1  | 2026-08-12T04:03:11Z ERROR could not connect to database
api-1  |   dial tcp 172.19.0.3:5432: connect: connection refused
api-1  | 2026-08-12T04:03:11Z FATAL exiting: database unreachable
api-1 exited with code 1

Illustrative output

The container exited three hundred milliseconds after it started. Meanwhile the database log says nothing is wrong:

Read-only / Safelogs
$ docker compose logs db
db-1  | The files belonging to this database system will be owned by user "postgres".
db-1  | initdb: creating directory /var/lib/postgresql/data ... ok
db-1  | performing post-bootstrap initialization ... ok
db-1  | PostgreSQL init process complete; ready for start up.
db-1  | 2026-08-12 04:03:19.402 UTC [1] LOG:  database system is ready to accept connections

Illustrative output

Eight seconds. initdb runs once on a first boot, and for those eight seconds the container is running, the network namespace exists, the veth pair is up, and nothing is listening on 5432. Compose did exactly what you asked. You asked for the wrong thing.

What depends_on actually waits for

So the corrected file is not subtle. It is two extra lines on the dependent and a healthcheck on the dependency:

services:
  api:
    image: myorg/api:1.4.0
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
      migrate:
        condition: service_completed_successfully

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s

  cache:
    image: redis:7

  migrate:
    image: myorg/api:1.4.0
    command: ["./migrate", "up"]
    depends_on:
      db:
        condition: service_healthy

The three conditions, and the two modifiers

KeyWaits forUse it for
condition: service_startedThe container to be running. Identical to short syntaxStateless dependencies that the app can retry against
condition: service_healthyThe dependency’s healthcheck to report healthyDatabases, brokers, caches, anything with a ready state
condition: service_completed_successfullyThe dependency to exit 0Schema migrations, seed jobs, one-shot fixture loaders
restart: trueβ€”Restarts this service when Compose updates the dependency
required: falseβ€”Downgrades a missing dependency from an error to a warning

service_completed_successfully is the one most stacks are missing. A migration job is not a service that stays up; it is a container that must run to completion before the API is allowed to start, and it must itself wait for the database to be healthy. Chaining the two conditions, as in the file above, gives you a real ordering: database healthy, then migration exits 0, then API starts. No sleep, no retry loop, no ordering assumption in a shell script.

required: false is the escape hatch for optional sidecars β€” a metrics exporter or a log shipper that you would rather have than not, but whose absence should not block the application. Without it, a dependency that fails to start takes the dependent down with it.

Verifying the wait, rather than assuming it

β€œRun it and see” is not verification here, because the whole defect is that running it usually works. Three checks that can actually fail:

Make Compose block on readiness

Service impact possibleup --wait
docker compose up -d --wait --wait-timeout 120
echo "exit status: $?"

--wait implies detached mode and holds the CLI until every service is running or healthy. It is the single most useful flag in a deploy script, because a non-zero exit is a deploy failure rather than a silent one. --wait-timeout bounds it so a hung dependency does not hang CI.

Prove the ordering actually happened

Read-only / Safestart times
$ docker inspect --format '{{.Name}} started={{.State.StartedAt}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $(docker compose ps -q)
/stack-db-1 started=2026-08-12T04:03:11.402Z health=healthy
/stack-migrate-1 started=2026-08-12T04:03:22.118Z health=none
/stack-api-1 started=2026-08-12T04:03:24.905Z health=healthy

Illustrative output

If api started before db reported healthy, the ordering is not being enforced no matter what the YAML says. This is the check that catches a depends_on you thought you added and did not, or one silently discarded by a merge with an override file.

Reproduce the cold boot on purpose

The reason this bug ships is that nobody tests the cold path. Test it:

Destructivecold boot drill
# This DELETES the stack data volumes. Never run it against production.
docker compose down -v
docker compose up -d --wait --wait-timeout 180
docker compose ps --format 'table {{.Service}}\t{{.Status}}'

Where depends_on stops applying

Three cases catch people, and all three are documented rather than surprising once you know to look:

  • docker compose restart does not re-evaluate conditions. It restarts containers; it does not recreate them and it does not re-run the dependency wait. A restart of the whole stack can put the API back up against a database that is still recovering. Prefer docker compose up -d for anything you care about ordering in.
  • --no-deps disables the whole mechanism. docker compose up -d --no-deps api is the correct way to redeploy one service without touching its dependencies, and it is also a way to start the API against a database that is not there.
  • Profiles cut dependency edges. If api depends on a service that is gated behind a profile you did not enable, the dependency is not started. Compose starts a targeted service and its declared dependencies, but a dependency sitting in an inactive profile is not silently activated unless that service is itself targeted.

The brittle answer, and the wrong one

# Wrong: assuming declaration order is start order
services:
  db:
    image: postgres:16
  api:
    image: myorg/api:1.4.0

Compose does not start services in declaration order. The order in the file carries no meaning at all; Compose builds a dependency graph from depends_on and starts independent services concurrently. Reordering the file changes nothing.

# Brittle: encoding the dependency as a host path
services:
  api:
    volumes:
      - ./db-data:/var/lib/postgresql/data:ro

This makes the API depend on the host’s directory being correct rather than on the database being ready. It appears to work locally, where the directory exists, and breaks on any host where it does not β€” silently, by bind-mounting an empty directory that Docker helpfully creates for you.

The finishing layer: application retries

Even with service_healthy, ordering is a startup-time guarantee only. It says nothing about the database that fails over at 14:00 on a Tuesday, and nothing about the connection pool that goes stale during a network blip.

# In the api, at connect time
for attempt in range(30):
    try:
        db.connect()
        break
    except OperationalError:
        time.sleep(min(2 ** attempt, 30))

Exponential backoff with a cap, not a fixed sleep(2) in a tight loop: a retry loop that hammers a struggling database every two seconds is a denial-of-service attack you wrote yourself. Most drivers have this built in β€” psycopg connection pools, pgx with a retry policy, HikariCP for the JVM β€” and the built-in version is usually better than a hand-rolled one.

depends_on: condition: service_healthy is the right start. Application retries are the right finish. Neither replaces the other.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. A stack uses short-syntax `depends_on: - db` and works on every redeploy but fails on the first boot after `docker compose down -v`. What is happening?

  2. Q2. Which condition should gate an API on a one-shot schema migration container?

  3. Q3. Which of these disable or bypass the depends_on wait? Select all that apply.

  4. Q4. Adding `restart: always` to a service that crashes on startup because its database is not ready is a correct fix for the race.

  5. Q5. Compose refuses an `up` with `dependency failed to start: container stack-db-1 has no healthcheck configured`. What does this tell you?

Passing score: 75%. Answers are checked in this browser.