Docker Compose healthcheck depends_on condition not working: why your container starts before Postgres is ready and the exact compose.yaml fix
Docker Compose healthcheck depends_on condition not working: why your container starts before Postgres is ready and the exact compose.yaml fix
Your app container starts before Postgres finishes booting, throws ECONNREFUSED, and exits — even though you wrote depends_on with a healthcheck condition. The fix is to make the healthcheck actually report unhealthy until the database accepts connections, then gate startup on it:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: example
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
app:
build: .
depends_on:
db:
condition: service_healthy
That block works on Docker Compose v2.20+ (the docker compose plugin, not the legacy docker-compose v1 binary). If it still fails, the cause is almost always one of the four issues below.
Why the condition is ignored: you are probably running Compose v1
Compose v1 (docker-compose, the Python implementation) ignores the long-form depends_on map and silently falls back to plain start-order. That is the single most common reason a correct-looking condition: service_healthy does nothing.
Check what you have:
docker compose version
# Docker Compose version v2.29.7 <-- good, plugin form
docker-compose version
# docker-compose version 1.29.2 <-- legacy, ignores condition
If both exist, use the space-separated plugin form (docker compose up) everywhere — in your Makefile, CI workflow, and README. The hyphenated v1 binary reached end of life in July 2023 per Docker’s own Compose v1 deprecation notice, so scripts still calling it on a modern host are running against a tool that no longer receives fixes.
A healthcheck that passes too early is worse than no healthcheck
The default Postgres image ships no healthcheck, so a container with none is treated as healthy the instant it is running — which is roughly 2–10 seconds before the initdb and startup sequence completes. The gate opens on the wrong signal.
Two rules make the check honest:
- Test the thing you actually need.
pg_isreadyreturns 0 only when the server accepts connections. Do not usetest: ["CMD", "true"]or a barepscheck — those report healthy on a half-started server. - Set
start_period. Failures during this window do not count againstretries. Without it, a slow first boot on a cold volume can exhaust your retries before initdb finishes.
Here is the failure mode that costs people hours: the healthcheck is correct, but interval is 30s and retries is 3. The container is healthy at second 8, but Compose does not re-poll until second 30, so your app waits 22 extra seconds for no reason. Drop interval to 5s while you are debugging, then tune it.
depends_on only gates startup — it does not restart your app when the database dies
condition: service_healthy guarantees ordering at up time and nothing after that. If Postgres crashes and restarts mid-run, your app is not restarted and will keep failing until you intervene.
Do not assume depends_on gives you resilience. It does not. For that you need either a restart policy plus retry logic in the app, or an orchestrator (Kubernetes, Nomad, ECS) with real readiness probes. For local development, a retry loop in the app is usually enough:
# entrypoint.sh — retry until the DB answers, then exec the real process
until pg_isready -h db -U postgres -d postgres; do
echo "waiting for postgres..."
sleep 1
done
exec "$@"
The retry loop is not redundant with the healthcheck. It covers the crash-and-restart case that depends_on cannot see.
The exact compose.yaml that survives a cold start and a mid-run restart
The following file is what to copy when you want both ordering and recovery. It uses service_healthy for startup ordering and a restart policy for the crash case.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 10
start_period: 15s
restart: unless-stopped
app:
build: .
depends_on:
db:
condition: service_healthy
restart: true # Compose v2.20+ — restart app if db restarts
restart: unless-stopped
volumes:
pgdata:
The restart: true key under depends_on is the one people miss. It tells Compose to restart the dependent service when the dependency restarts — added in Compose v2.20.0 (Docker release notes, 2024). Without it, a Postgres restart leaves your app pointing at a dead socket.
Common failure modes and how to read them
The container is “healthy” but the app still gets connection refused
This happens when the healthcheck command targets the wrong user or database. pg_isready -U postgres succeeds against the default postgres database even if your app connects to appdb with user app. Match the healthcheck to the credentials in your app’s connection string.
Compose reports the dependency as unhealthy and aborts
Read the actual healthcheck output — do not guess:
docker inspect --format='{{json .State.Health}}' $(docker compose ps -q db) | jq
The Log array shows every probe and its exit code. An exit code of 2 from pg_isready means “no response”; 1 means “rejecting connections”; 0 means ready. If you see 1 repeatedly, the server is up but still in recovery or rejecting your user — usually a volume permission problem on a bind mount.
It works on macOS but not on Linux CI
Bind-mounted Postgres data directories hit permission and fsync differences between Docker Desktop’s VM and native Linux. Named volumes (as in the file above) behave consistently across both. If you must bind-mount, ensure the host directory is owned by the Postgres UID (999 in the official image).
What to watch for after you ship this
The failure mode to watch for: your healthcheck interval is tuned for a fast local SSD, then someone runs the same compose file on a slower CI runner or a laptop under load. The start_period expires, retries run out, and the stack aborts with “dependency failed to start: container is unhealthy.” Set start_period generously (15–30s for Postgres) and keep interval short — the two knobs serve different purposes and confusing them is what makes a working config look broken on a different machine.