Learn how to start Compose services in the right order with depends_on, and how health checks make the API wait until the database is truly ready.

Bishal Biswas
Self Learner
The “database is not ready” problem
You run docker compose up for the first time. The API starts, tries to connect to PostgreSQL, and crashes:
api-1 | Error: connect ECONNREFUSED 172.20.0.2:5432
api-1 exited with code 1
db-1 | LOG: database system is ready to accept connections
Look at the order. The API tried to connect before the database was ready. A PostgreSQL container needs a few seconds on first start to create its files. During that time the container is “running”, but the database inside it is not accepting connections yet.
In this lesson you will fix this properly. You will learn:
- what
depends_ondoes, and what it does not do - how to add a health check to a Compose service
- how
condition: service_healthymakes one service wait for another - how to run one-off jobs, such as database migrations, before the app starts
depends_on: the short form
The depends_on key tells Compose that one service needs another. In its short form it is a list of service names:
services:
api:
build: .
depends_on:
- db # start db before api
db:
image: postgres:17-alpine
This changes two things:
- Start order.
docker compose upstartsdbfirst, thenapi.docker compose downstops them in the reverse order. - Automatic start.
docker compose up apialso startsdb, becauseapineeds it.
But here is the important part: the short form only waits until the db container has started. It does not wait until PostgreSQL inside it is ready. So the crash from the start of this lesson can still happen.
Note: “Container started” and “app ready” are different moments. A container starts in milliseconds. The app inside may need seconds to load data, run setup or open its port.
Health checks: how Docker knows an app is ready
A health check is a small command that Docker runs inside the container again and again. If the command succeeds (exit code 0), the container is healthy. If it keeps failing, the container is unhealthy.
You met the HEALTHCHECK Dockerfile instruction in Health Checks: Telling Docker Your App Is Working. In Compose you can write the same thing with the healthcheck key, which is handy for images you did not build yourself, such as postgres.
PostgreSQL ships with a tool called pg_isready that checks if the server accepts connections. It is perfect for this:
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: notes
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: notes
healthcheck:
test: ["CMD", "pg_isready", "-U", "notes", "-d", "notes"]
interval: 5s # run the check every 5 seconds
timeout: 3s # a check that takes longer than 3s counts as failed
retries: 5 # unhealthy after 5 failures in a row
start_period: 10s # failures in the first 10s do not count
Here is what each option means:
| Option | Meaning |
|---|---|
test |
The command to run. CMD runs it directly. CMD-SHELL runs it through a shell, so you can use && or || |
interval |
Time between checks |
timeout |
How long one check may take |
retries |
How many failures in a row make the container unhealthy |
start_period |
A warm-up time. Failures here do not count, but one success marks it healthy straight away |
Start it and watch the status change:
docker compose up -d db
docker compose ps
NAME IMAGE COMMAND SERVICE STATUS PORTS
notes-db-1 postgres:17-alpine "docker-entrypoint.s…" db Up 3 seconds (health: starting) 5432/tcp
A few seconds later:
NAME IMAGE COMMAND SERVICE STATUS PORTS
notes-db-1 postgres:17-alpine "docker-entrypoint.s…" db Up 15 seconds (healthy) 5432/tcp
depends_on: the long form with conditions
Now connect the two ideas. The long form of depends_on lets you say what to wait for:
services:
api:
build: .
depends_on:
db:
condition: service_healthy # wait until db's health check passes
db:
image: postgres:17-alpine
healthcheck:
test: ["CMD", "pg_isready", "-U", "notes", "-d", "notes"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
Now docker compose up starts db, waits until it is healthy, and only then starts api. The crash is gone.
There are three conditions you can use:
| Condition | Compose waits until… | Typical use |
|---|---|---|
service_started |
the container has started (same as the short form) | services that are ready instantly |
service_healthy |
the health check passes | databases, caches, other APIs |
service_completed_successfully |
the container has exited with code 0 | one-off jobs like migrations |
If the dependency never becomes healthy, Compose stops and shows an error instead of starting the app:
dependency failed to start: container notes-db-1 is unhealthy
Health checks for Redis and for notes-api
Most services have a simple way to check themselves. For Redis, the redis-cli ping command answers PONG when the server is ready:
cache:
image: redis:8-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
For your own notes-api, the /health endpoint already returns ok. The node:24-alpine image has no curl, but Node itself has fetch, so you can use a one-line Node script:
api:
build: .
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
Tip: If the image already has a
HEALTHCHECKin its Dockerfile, Compose uses it automatically. You only needhealthcheckincompose.yamlto add one or to change it. Usehealthcheck: { disable: true }to turn an image’s check off.
One-off jobs: migrations before the app
Many apps need a setup step before they start, such as creating database tables. This is called a migration. You can run it as its own short-lived service, and make the API wait until it finishes successfully.
services:
migrate:
build: .
command: ["node", "src/migrate.js"] # creates tables, then exits
environment:
DATABASE_URL: postgres://notes:devpassword@db:5432/notes
depends_on:
db:
condition: service_healthy
api:
build: .
depends_on:
migrate:
condition: service_completed_successfully # wait for exit code 0
db:
condition: service_healthy
The order is now: db starts and becomes healthy, migrate runs and exits with code 0, and then api starts. If the migration fails, the API does not start, which is exactly what you want.
Waiting from the command line with –wait
When a script or a test needs the whole stack ready, add --wait to docker compose up. Compose then waits until all services are running and healthy before it returns:
docker compose up -d --wait
curl http://localhost:3000/health
ok
This is very useful in CI pipelines, where the next step runs tests against the app. You will build such a pipeline in Building and Pushing Images in GitHub Actions.
depends_on is not enough on its own
Health checks fix the startup problem. But databases can also restart later: after an upgrade, a crash or a server reboot. Compose does not stop your API when the database goes away for a moment.
So your app should still be resilient: when a connection fails, it should wait a little and try again, instead of crashing forever. A simple retry loop is enough for notes-api:
// Try to connect up to 10 times, waiting 2 seconds between tries
async function connectWithRetry(connect, tries = 10) {
for (let i = 1; i <= tries; i++) {
try {
return await connect();
} catch (err) {
console.log(`database not ready (try ${i}/${tries}): ${err.message}`);
await new Promise((r) => setTimeout(r, 2000));
}
}
throw new Error('could not connect to the database');
}
Add a restart policy as a safety net, as you learned in Restart Policies and Resource Limits:
api:
restart: unless-stopped
Health checks, depends_on and retries work together. None of them alone is the full answer.
Common mistakes
Using the short form and expecting it to wait
depends_on: [db] only orders the start. It does not wait for PostgreSQL. Use the long form with condition: service_healthy, and give db a health check.
A health check that uses a missing tool
test: ["CMD", "curl", "-f", "http://localhost:3000/health"] fails forever in images that do not have curl, such as node:24-alpine. Check what is in the image with docker compose exec api which curl, or use a tool the image already has.
Checking localhost from the wrong place
The health check runs inside the container. So 127.0.0.1 means the container itself, which is correct. Do not use the host port you published with ports. Use the port the app listens on inside the container.
A timeout that is too short
If the check sometimes takes longer than timeout, the container may flip between healthy and unhealthy. Give slow services a longer timeout and a generous start_period.
Quick recap
- Short
depends_ononly controls start order. It does not wait for the app to be ready. - A health check is a command Docker runs repeatedly. Exit code 0 means healthy.
- Use
pg_isreadyfor PostgreSQL,redis-cli pingfor Redis, and your own/healthendpoint for your app. - Long
depends_onwithcondition: service_healthymakes a service wait for a healthy dependency. service_completed_successfullyis for one-off jobs such as migrations.docker compose up --waitwaits for a healthy stack. Your app should still retry connections.
Next, you will make Compose great for day-to-day coding in Local Development with Compose: Bind Mounts and Watch.
# compose.yaml for notes-api: start order with health checks
# Start everything and wait until it is healthy: docker compose up -d --wait
# Check the status column: docker compose ps
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: notes
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: notes
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
# pg_isready exits with 0 when PostgreSQL accepts connections
test: ["CMD", "pg_isready", "-U", "notes", "-d", "notes"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
cache:
image: redis:8-alpine
healthcheck:
# redis-cli ping answers PONG when Redis is ready
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
migrate:
# A one-off job: create tables, then exit with code 0
build: .
command: ["node", "src/migrate.js"]
environment:
DATABASE_URL: postgres://notes:devpassword@db:5432/notes
depends_on:
db:
condition: service_healthy
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://notes:devpassword@db:5432/notes
REDIS_URL: redis://cache:6379
depends_on:
# Wait for the migration to finish, and for db and cache to be healthy
migrate:
condition: service_completed_successfully
db:
condition: service_healthy
cache:
condition: service_healthy
healthcheck:
# node:24-alpine has no curl, so use Node's built-in fetch
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
# A safety net if the app crashes later
restart: unless-stopped
volumes:
pgdata:
Previous
Environment Variables and Secrets in Compose
Next
Local Development with Compose: Bind Mounts and Watch
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready