Dependencies, Healthchecks, and Startup Order in Compose
Your API container starts before the database is ready to accept connections. The app crashes, the restart policy brings it back, the database still is not ready, the app crashes again. Eventually the database finishes initializing and the app stays up — but the first 30 seconds of logs are a mess of connection errors. This is the most common Compose startup problem, and the solution is not retry logic in your application — it is telling Compose to wait until the database is actually ready.
TL;DR
depends_on without conditions only waits for the container to start — not for the application inside to be ready. Use depends_on with condition: service_healthy and define healthchecks on your dependencies. For one-time setup tasks (database migrations, schema creation), use condition: service_completed_successfully. These conditions turn Compose startup from “start everything and hope” into a deterministic sequence.
Context
Docker Compose starts services roughly in parallel. depends_on introduces ordering, but the default behavior — condition: service_started — only waits for the container to exist, not for the application to be functional. A PostgreSQL container is “started” the moment the process launches, but it may take several seconds to initialize the data directory and begin accepting connections. The gap between “container started” and “application ready” is where most startup failures live.
Analysis / Key Findings
The Three Conditions
depends_on:
db:
condition: service_started # Default — container is running
db:
condition: service_healthy # Container's healthcheck passes
migrations:
condition: service_completed_successfully # Container exited with code 0
| Condition | Waits for | Use case |
|---|---|---|
service_started | Container process exists | Services that start instantly |
service_healthy | Healthcheck returns healthy | Databases, caches, APIs |
service_completed_successfully | Container exits with code 0 | Migrations, seed scripts, init tasks |
service_healthy in Practice
The most useful pattern — wait for the database to actually accept connections:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
api:
build: .
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/appdb
The api service will not start until pg_isready confirms PostgreSQL is accepting connections. No more connection refused errors on startup.
service_completed_successfully: The Init Container Pattern
Some tasks need to run once before the main application starts — database migrations, schema creation, cache warming. Compose handles this with a service that runs to completion:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
migrate:
build: .
command: npx prisma migrate deploy
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/appdb
api:
build: .
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/appdb
Startup sequence:
dbstarts and becomes healthy (PostgreSQL accepting connections)migrateruns and exits with code 0 (migrations applied)apistarts (database is ready and schema is current)
If migrate fails (exit code != 0), api will not start. This prevents the application from running against an unmigrated database.
Healthcheck Configuration in Compose
Healthchecks defined directly in the compose file:
services:
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
mysql:
image: mysql:8
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s # MySQL can be slow to initialize
environment:
MYSQL_ROOT_PASSWORD: secret
api:
build: .
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
The Common Trap
# This does NOT wait for the database to be ready
services:
api:
depends_on:
- db
db:
image: postgres:16
The short form depends_on: [db] is equivalent to condition: service_started. It waits for the PostgreSQL container to exist — not for PostgreSQL to be accepting connections. The API will likely start before the database is ready, causing connection errors.
Always use the long form with condition: service_healthy for databases and services that have a startup delay.
Restart Behavior Interaction
Restart policies and dependencies interact in important ways:
depends_onconditions are only enforced on initialdocker compose up. If a dependency goes down and comes back, dependent services are not automatically restarted.- If a service with
restart: unless-stoppedcrashes, Docker restarts it — but it does not re-checkdepends_onconditions. The dependency might also be down. - For true dependency-aware restarts, you need orchestration (Swarm, Kubernetes) or an external process manager.
For single-server deployments, combining healthchecks with restart policies provides a reasonable level of resilience: if the API crashes because the database is down, the restart policy brings the API back, and eventually the database recovers and the API reconnects.
Tuning start_period and retries
start_period gives a service time to boot before failed healthchecks count. Set it to at least the expected startup time:
- PostgreSQL: 5-10s
- MySQL: 15-30s (first-time initialization can be slow)
- JVM applications: 15-60s depending on size
- Node.js: 5-15s
retries determines how many consecutive failures trigger an unhealthy state. Too few (1-2) causes false alarms from momentary slowdowns. Too many (10+) delays detection of real problems. 3-5 is a reasonable default.
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
start_period: 45s # JVM app needs time to boot
retries: 3
Conclusion
The difference between service_started and service_healthy is the difference between “container exists” and “application is ready.” Use service_healthy for anything that has a startup delay — databases, caches, APIs. Use service_completed_successfully for one-time init tasks. Define healthchecks on every service that other services depend on. The investment is a few lines of YAML; the payoff is a stack that starts cleanly every time instead of racing through connection errors until things stabilize.