Docker & ContainersIX Β· Docker ComposeDependencies
Dependencies and startup order
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
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:
$ docker compose logs apiapi-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 1Illustrative output
The container exited three hundred milliseconds after it started. Meanwhile the database log says nothing is wrong:
$ docker compose logs dbdb-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 connectionsIllustrative 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
| Key | Waits for | Use it for |
|---|---|---|
condition: service_started | The container to be running. Identical to short syntax | Stateless dependencies that the app can retry against |
condition: service_healthy | The dependencyβs healthcheck to report healthy | Databases, brokers, caches, anything with a ready state |
condition: service_completed_successfully | The dependency to exit 0 | Schema 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
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
$ 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=healthyIllustrative 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:
# 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 restartdoes 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. Preferdocker compose up -dfor anything you care about ordering in.--no-depsdisables the whole mechanism.docker compose up -d --no-deps apiis 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
apidepends 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
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?
Q2. Which condition should gate an API on a one-shot schema migration container?
Q3. Which of these disable or bypass the depends_on wait? Select all that apply.
Q4. Adding `restart: always` to a service that crashes on startup because its database is not ready is a correct fix for the race.
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.