Docker Compose healthcheck depends_on condition not working: why your container starts before Postgres is ready
Docker Compose healthcheck depends_on condition not working: why your container starts before Postgres is ready
Your app container exits with ECONNREFUSED 127.0.0.1:5432 even though you wrote depends_on: postgres: condition: service_healthy, and the fix is almost always that the healthcheck is defined on the wrong service, or the Compose file is being read by a Compose version that ignores condition.
The diagnostic one-liner:
docker compose config | grep -A4 healthcheck
Now here is what it actually does. docker compose config prints the fully resolved, merged Compose model exactly as the engine will see it. If healthcheck does not appear under the Postgres service in that output, no amount of condition: service_healthy will help — Compose has nothing to wait on, so it treats the dependency as satisfied the moment the container is created.
Why does depends_on condition service_healthy still start my container immediately?
condition: service_healthy only blocks startup while the dependency has a healthcheck; a service with no healthcheck block is considered healthy by definition, so Compose starts the dependent container right away.
This is the failure mode that costs people hours: the YAML looks correct, Compose emits no warning, and the app races Postgres on every cold start. The three causes, in order of frequency:
- The healthcheck is on the app, not the database. People add a healthcheck to the service that needs to wait, then reference
service_healthyon the database that has none. - The command exits 0 before Postgres accepts connections.
pg_isreadyreturns success while the server is still in recovery or not yet listening on TCP. - Compose v2 vs. the old v1 binary. The
condition:form was unsupported in Compose file format v2.x under the legacydocker-compose(Python) tool, which silently ignored it and only waited for the container to be created.
Check which binary you are running before debugging anything else:
docker compose version
# Docker Compose version v2.29.7 <-- the plugin, supports condition: service_healthy
If that command errors and docker-compose version answers instead, you are on the legacy v1 binary. The Compose Specification (the format the v2 plugin implements) documents depends_on with condition: service_healthy as a first-class field; v1’s file-format v2/v3 parsers did not.
What does a healthcheck that actually blocks startup look like?
A working healthcheck must live on the dependency, must run a command that fails until the service is genuinely ready, and must set start_period so slow first boots are not counted as failures.
Here is a complete, copy-paste-ready compose.yaml known to work on Docker Engine 27.x with Compose v2.29+:
services:
postgres:
image: postgres:16.4
environment:
POSTGRES_PASSWORD: example
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app -h 127.0.0.1"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
volumes:
- pgdata:/var/lib/postgresql/data
app:
build: .
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgres://postgres:example@postgres:5432/app
volumes:
pgdata:
Two details in that block do the real work. First, -h 127.0.0.1 forces pg_isready to test the TCP listener rather than the Unix socket — the socket can accept connections during the init phase before the server is ready for your app. Second, start_period: 30s gives the initdb and recovery pass room to finish without burning retries; failures during the start period do not count against retries.
Verify the gate is real by watching the state transition:
docker compose up -d
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
# postgres Up 12 seconds (healthy)
# app Up 3 seconds
If app shows Up before postgres shows (healthy), the condition is not being applied — go back to docker compose config and confirm the healthcheck survived the merge.
Why does pg_isready pass while my app still gets connection refused?
pg_isready reports the server is accepting connections, but it does not guarantee the database your app targets exists, nor that any init scripts have finished — so a healthcheck built only on pg_isready can go green too early for schema-dependent apps.
For a plain Postgres container the window is small. It widens when you mount /docker-entrypoint-initdb.d scripts: the entrypoint runs them after the server is up, and pg_isready does not know they are still executing. The official postgres image documentation notes the init scripts run during first startup only, on an empty data directory.
Do not do this — a bare pg_isready with no database or host:
test: ["CMD", "pg_isready"] # passes on the socket, ignores your DB
Do this instead — assert the specific database your app will query:
test: ["CMD-SHELL", "pg_isready -U postgres -d app -h 127.0.0.1"]
If your app needs tables, the healthcheck is the wrong tool. Put migrations in the app’s own startup path (retry with backoff) or a one-shot migration service that the app depends on with condition: service_completed_successfully. Compose supports that condition too, and it is the correct primitive for “wait for this job to finish”.
Why does it work locally but fail in CI or on a fresh machine?
Cold starts are the difference: on a machine with a warm image cache and an existing data volume, Postgres is ready in under a second, so the race never fires — CI and new laptops hit the slow path every time.
Measure it rather than guess. Time the healthcheck from container creation to first healthy state:
docker compose up -d postgres
time until docker inspect --format '{{.State.Health.Status}}' \
"$(docker compose ps -q postgres)" | grep -q healthy; do sleep 0.5; done
On a cold pull with an empty volume, first-boot initdb on a CI runner routinely takes longer than the default retries × interval budget. If your total budget is 5s × 10 = 50s and initdb takes 40s plus a slow disk, you are one flaky runner away from a red pipeline. Raise start_period to cover the observed worst case, not the average.
Also confirm the CI runner is not using an older Compose. GitHub-hosted runners have shipped Compose v2 for years, but self-hosted images and older base images may still carry the v1 binary — and v1 ignores condition silently. Pin it:
docker compose version || { echo "legacy compose detected"; exit 1; }
What is the failure mode to watch for?
The silent one: a Compose file where depends_on uses the short list syntax (depends_on: [postgres]) or where the healthcheck sits on the wrong service — both resolve to “wait for container creation”, which is nearly instant, so your app starts, connects, fails, and restarts in a loop that looks like an application bug.
Two habits prevent it. Run docker compose config after every change to the dependency graph and read the resolved output, not the source file. And make the app itself resilient: a startup retry loop with backoff on the database connection is the only defence that survives a Compose file someone else edits six months from now. The healthcheck orders startup; it does not guarantee readiness for your schema.