Lab notes · N°08 · · 3 min read
How to Make Docker Compose Wait for Postgres with depends_on Healthchecks
depends_on orders starts, not readiness: the long syntax with condition service_healthy, a pg_isready healthcheck, and the compose v1/v2 version gotchas.
- Written by
- Kamol Nazarov
- Published
- Tags
- docker · compose · postgres
- Follow
- RSS feed
The note
Saturday deploy, five minutes of quiet, then the alerts: the intake API container was crash-looping on the VPS with ECONNREFUSED 127.0.0.1:5432. The compose file had depends_on right there under the api service, Postgres was listed first, and Postgres was fine � eventually. The app had simply asked too early. If you've searched "docker compose depends_on service_healthy" or "docker compose wait for postgres healthy" after discovering that depends_on lied to you, this is the whole picture: what it actually promises, the two pieces that make it promise more, and the traps that survive the fix.
What depends_on actually promises
Plain depends_on orders container starts, nothing else:
services:
db:
image: postgres:17
api:
build: .
depends_on:
- db # api starts after db STARTS � not after db is READY
Compose starts db, waits a beat, starts api. But a Postgres container that's "started" is still a second or two from initdb finishing and the port accepting connections � much longer on a fresh volume. The api hits the port mid-boot and gets refused. On my warm dev machine the database was already initialized, so the race never showed; on a cold VPS it showed every time.
The fix is two pieces, not one
condition: service_healthy � the depends_on condition long syntax � only works if the dependency has a healthcheck to evaluate. Miss either half and you're back where you started:
services:
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: intake
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d intake"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
api:
build: .
depends_on:
db:
condition: service_healthy
What each piece does:
- The healthcheck runs
pg_isreadyinside the db container every 5s;start_period: 10sgives first-boot work a grace window where failures don't count;retries: 10requires sustained failure before the container is flagged unhealthy. Define it here in compose, per project � aHEALTHCHECKin the Dockerfile works too, but compose overrides it if both exist, and per-app details (the user and database name) belong next to the rest of the config. - The long depends_on makes the api service wait until that check passes. If you write
condition: service_healthyagainst a service with no healthcheck defined, Compose tells you immediately � that combination is invalid, which is better than the silent version of the mistake.
The pg_isready first-boot trap
pg_isready reports success when the server accepts connections � and on a brand-new volume, Postgres accepts connections, finishes initializing, then restarts itself once. A client can squeeze into that window, connect, and get dropped a second later. The healthcheck narrows the race; it can't delete it. Two defenses:
- Point the check at your real user and database (
-U app -d intake), so success means "the actual database exists," not just "a Postgres answered." - Keep a small connect-retry in the app anyway (
pgpool with connectionRetryInterval, or ten attempts with a one-second sleep). Gating the start protects you from the boot race; retry protects you from the network existing at all. Kubernetes people learned this the hard way years ago � readiness probes and retries, never one or the other.
The third condition nobody uses
The spec has one more condition worth knowing: service_completed_successfully. It waits for a service to run to a successful exit � perfect for one-shot migration containers that must finish before the app starts:
migrate:
build: .
command: npx prisma migrate deploy
depends_on:
db:
condition: service_healthy
api:
build: .
depends_on:
migrate:
condition: service_completed_successfully
For the record, the three conditions and what they guarantee:
| Condition | Waits until |
|---|---|
service_started |
the container started (same as short syntax) |
service_healthy |
the healthcheck passes |
service_completed_successfully |
the container ran and exited 0 |
The version gotchas
Two historical footguns still haunt every tutorial written before ~2022:
- The
version:field is dead. Compose v2 (thedocker composeplugin, no hyphen) uses the unified Compose Spec and ignores it; you can deleteversion: "3.9"from the top of the file. Old habits from v1 config files are the main reason people still believe conditions "aren't supported." - They genuinely weren't, once. The old v3 file format dropped
conditionsupport for Swarm compatibility, which is why half the internet says depends_on conditions don't exist. The Compose Spec brought them back, anddocker compose v2honors them � but if some ancientdocker-composev1 binary runs your file, conditions may error or (worse) be ignored. If you can't drop the hyphen, at least test on the same binary you deploy with.
One sentence to keep: depends_on only ever ordered starts � condition: service_healthy plus a real healthcheck on the dependency is what makes it mean "wait until ready," and an app-side retry is what keeps it meaning that after the race you can't see.