Skip to main content

How to manage service dependencies in Docker Compose?

Service dependencies in Docker Compose are about more than start order. The dep being "started" rarely means "ready to accept connections". The right pattern: depends_on: condition: service_healthy with a healthcheck on the dependency.

Theory

TL;DR

  • Plain depends_on: [db] = start order only. Compose starts db first, then api. Does NOT wait for db to be ready.
  • depends_on: db: condition: service_healthy = waits until db's healthcheck passes. This is what you want for production.
  • Three conditions: service_started, service_healthy, service_completed_successfully (for one-off init containers).
  • Without depends_on, services start in parallel. With it, Compose enforces the order.
  • For non-Compose orchestrators, the same idea exists: K8s initContainers, Swarm dependency-via-healthcheck-on-routes.

Quick example

yaml
# compose.yaml services: api: image: myapp environment: DATABASE_URL: postgres://postgres:dev@db:5432/app depends_on: db: condition: service_healthy redis: condition: service_started migrate: condition: service_completed_successfully db: image: postgres:16 environment: POSTGRES_PASSWORD: dev POSTGRES_DB: app healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s retries: 5 start_period: 5s redis: image: redis:7 migrate: image: mymigrator depends_on: db: condition: service_healthy command: ["npm", "run", "migrate"]
bash
$ docker compose up -d [+] Running 4/4 ✔ Container db Healthy 2.3s ✔ Container redis Started 2.4s ✔ Container migrate Exited 8.7s # ran, succeeded, exited 0 ✔ Container api Started 8.8s # waited for migrate to finish

Four services, three different dependency conditions, perfect ordering: db ready → migrate runs → migrate succeeds → api starts (with redis already up).

The three conditions

service_started (the default)

yaml
depends_on: - db # OR explicitly: depends_on: db: condition: service_started

Waits until the container starts. Does NOT wait for the app inside to be ready. Almost never what you want.

service_healthy

yaml
depends_on: db: condition: service_healthy

Waits until the dep's healthcheck passes. Requires db to have a healthcheck: defined. The right default for runtime dependencies.

If db has no healthcheck, this fails the dependent's start (Compose errors out with "service has no healthcheck").

service_completed_successfully

yaml
depends_on: migrate: condition: service_completed_successfully

Waits for the dep to exit with code 0. Used for one-shot init/migration containers — they run, do their job, exit, then the main service starts.

Why depends_on alone is not enough

yaml
# WRONG: only orders starts; api starts while postgres is still booting services: api: depends_on: - db db: image: postgres:16
bash
$ docker compose up Container db Started Container api Started # but db needs ~3 seconds to actually accept connections # api hits db → Connection refused → api crashes or retries

Most databases need 1-5 seconds after "started" before they accept queries. depends_on alone does not know this. The healthcheck does.

App-level retry as a safety net

Even with service_healthy, transient db disconnects happen at runtime. Production apps should retry with backoff:

js
// Pseudo-code async function connectDB() { for (let i = 0; i < 10; i++) { try { return await pg.connect(...); } catch (e) { console.log(`db retry ${i+1}/10: ${e.message}`); await sleep(1000 * (i + 1)); } } throw new Error('db unreachable after 10 attempts'); }

App-level retry + Compose service_healthy together = robust startup.

Common mistakes

Using plain depends_on for a real dependency

yaml
# WRONG depends_on: [db]

Starts api before db is ready. App fails on first connection.

Forgetting the dep needs a healthcheck

yaml
services: api: depends_on: db: condition: service_healthy db: image: postgres:16 # no healthcheck:
ERROR: ...service "db" has no healthcheck defined

Fix: add a healthcheck: block to db.

Wait-for scripts are not always needed

The pre-condition: service_healthy era required scripts like wait-for-it or dockerize:

dockerfile
CMD ["./wait-for-it.sh", "db:5432", "--", "node", "server.js"]

With Compose v3+ and service_healthy, you do not need these for inter-service start order. They are still useful for external dependencies (cloud DBs reached over the network).

Cyclic dependencies

yaml
services: a: depends_on: [b] b: depends_on: [a]
ERROR: dependency cycle detected

Compose refuses. Real dependency graphs must be acyclic.

Dependency only honored at startup, not at restart of a single service

bash
$ docker compose restart api # Restarts only api. Does NOT verify db's health again.

The healthcheck-gated dependency is enforced during up. restart is a quicker operation. Usually fine; rarely a foot-gun.

Real-world patterns

Pattern: db + migrator + app

yaml
services: db: image: postgres:16 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 3s retries: 10 migrate: image: myapp command: ["npm", "run", "migrate"] depends_on: db: condition: service_healthy app: image: myapp depends_on: migrate: condition: service_completed_successfully db: condition: service_healthy

db healthy → migrate runs → migrate exits 0 → app starts. Three conditions, deterministic startup.

Pattern: web + api + db

yaml
services: db: image: postgres:16 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s api: build: ./api depends_on: db: condition: service_healthy healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"] web: image: nginx depends_on: api: condition: service_healthy ports: ["80:80"]

Three-tier dependency chain, each gate is healthcheck-driven.

Follow-up questions

Q: Does depends_on work across multiple Compose files?


A: Yes — depends_on resolves within the merged result of all -f files. References must point at services defined somewhere in the merged config.

Q: Can a service depend on multiple services with mixed conditions?


A: Yes. Use the map form to set per-dep conditions:

yaml
depends_on: db: { condition: service_healthy } redis: { condition: service_started } migrate: { condition: service_completed_successfully }

Q: Does depends_on carry over to docker compose run?


A: Yes — Compose starts deps when you do docker compose run --rm api .... Disable with --no-deps.

Q: What if my dep is an external service (like AWS RDS)?


A: Compose cannot health-check it. Either add a small wrapper container with a healthcheck that probes the external service, or rely on app-level retry.

Q: (Senior) How does this map to Kubernetes?


A: Kubernetes does not have depends_on for pods directly. The equivalents: initContainers (run before main containers, must succeed), readiness probes (gate traffic to the pod), and external orchestration tools (Argo Workflows, etc.) for complex dependencies. Helm charts often hardcode startup-order assumptions using deployments + services + readiness probes; the explicit dependency graph is a Compose-flavored convention.

Examples

Real prod-shape Compose

yaml
services: db: image: postgres:16 environment: POSTGRES_PASSWORD: ${DB_PASSWORD:-dev} POSTGRES_DB: app volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d app"] interval: 3s timeout: 2s retries: 10 start_period: 5s restart: unless-stopped migrate: image: myapp:${TAG:-latest} command: ["npm", "run", "migrate"] depends_on: db: condition: service_healthy environment: DATABASE_URL: postgres://postgres:${DB_PASSWORD:-dev}@db:5432/app restart: "no" api: image: myapp:${TAG:-latest} depends_on: migrate: condition: service_completed_successfully db: condition: service_healthy environment: DATABASE_URL: postgres://postgres:${DB_PASSWORD:-dev}@db:5432/app healthcheck: test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/health"] interval: 30s retries: 3 start_period: 10s restart: unless-stopped web: image: nginx:1.27-alpine depends_on: api: condition: service_healthy ports: ["80:80"] volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro restart: unless-stopped volumes: pgdata:

Four-service stack, deterministic startup, every service has its restart policy and healthcheck, migrations gate api.

Init container pattern (one-shot setup)

yaml
services: db-init: image: postgres:16 command: ["sh", "-c", "echo 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements;' | psql -h db -U postgres"] depends_on: db: condition: service_healthy restart: "no" app: image: myapp depends_on: db-init: condition: service_completed_successfully

db-init is a one-shot job. It runs after db is healthy, performs setup, exits 0, then app starts.

When the dep is external

yaml
services: db-check: image: alpine command: ["sh", "-c", "until nc -z external-db.aws.com 5432; do sleep 1; done"] restart: "no" api: image: myapp depends_on: db-check: condition: service_completed_successfully

A tiny check container that waits for an external dep, then exits. The api is gated on it.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Comments

No comments yet