Docker Compose Healthcheck Depends On Not Working: Fix for Node and Postgres
Docker Compose Healthcheck Depends On Not Working: Fix for Node and Postgres
Your Node container starts before Postgres is ready, crashes on the first query, and Compose reports a healthy stack while your app is dead. The fix is a healthcheck on the database plus depends_on with condition: service_healthy — and the reason it “doesn’t work” is almost always that one of those two pieces is missing, misspelled, or pointing at the wrong stage.
Here is the smallest Compose file that actually gates startup, known to work on Docker Compose v2.24+ (the docker compose plugin, not the legacy docker-compose Python binary):
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
api:
build: .
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://app:secret@db:5432/appdb
Now here is what it actually does, and where it silently fails.
Depends_on alone only waits for the container to start, not for Postgres to accept connections
Plain depends_on controls start order, not readiness — Docker starts the db container, immediately considers the dependency satisfied, and starts api in parallel.
A Postgres container is “running” seconds before it is “ready.” The image entrypoint has to initialize the data directory, run initdb, start the postmaster, and only then open the port. Depending on disk speed and whether the volume is fresh, that window is commonly 1–5 seconds. Your Node process connects in that window, gets ECONNREFUSED, and exits.
The failure mode that costs people hours: the app restarts, and by the time it restarts Postgres is up, so it works on the second attempt. That makes it look like a flaky app bug rather than a startup-ordering bug. If your logs show a crash followed by a clean boot with no code change, suspect ordering before you suspect your connection pool.
The condition: service_healthy key is what turns a healthcheck into a gate
condition: service_healthy tells Compose to hold the dependent service until the target’s healthcheck reports healthy — without it, the healthcheck runs but nothing waits on it.
Three things break this in practice:
- Wrong key name. It is
condition, and the value isservice_healthywith an underscore.service-healthyandhealthyare both invalid and Compose will reject the file or ignore the gate depending on version. - Healthcheck on the wrong service. The healthcheck must live on the service being waited for (the database), not the service doing the waiting. Putting it on
apidoes nothing for ordering. - Legacy binary. The old
docker-composev1 did not supportconditionat all. Rundocker compose versionand confirm you are on v2.x. If the output starts with “docker-compose version 1.”, every long-formdepends_onin your file is being ignored.
You can verify the gate is real by inspecting status:
docker compose up -d
docker compose ps --format "table {{.Service}}\t{{.Status}}"
You want to see db as Up X seconds (healthy) and api as Up. If db shows (health: starting) and api is already running, the condition is not being applied.
pg_isready returns success before the database is ready to accept queries
pg_isready answers one question — is the server accepting connections — and during initialization Postgres briefly accepts connections on the Unix socket while rejecting them on TCP.
That is the classic “healthcheck passes, app still crashes” report. The healthcheck fires against the socket, returns 0, Compose marks the service healthy, and the Node client connecting over TCP to db:5432 still gets refused.
Two changes fix it. First, force the check over TCP so it tests the same path your app uses:
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
Second, if your app runs migrations or writes on boot, a connection check is not enough. Test an actual query:
healthcheck:
test: ["CMD-SHELL", "psql -U app -d appdb -c 'SELECT 1' || exit 1"]
interval: 5s
timeout: 5s
retries: 10
start_period: 15s
This is slower per check and pulls the client binary into the container — fine for postgres:16-alpine, which ships psql. For a slimmed image, install it in a custom Dockerfile or stay with the TCP pg_isready form.
start_period is the setting most people omit and then blame the retries
start_period is a grace window during which failed checks do not count toward retries — omit it and a slow first boot burns through your retry budget before Postgres has finished initializing.
Without start_period, a retries: 3 healthcheck with interval: 5s gives Postgres about 15 seconds to become healthy. On a fresh volume with a slow disk or a cold image pull, that is not always enough, and the container is marked unhealthy permanently — Compose does not recover a service once it fails its retry budget without a restart.
The rule of thumb: set start_period to roughly the worst-case first-boot time, and keep retries for transient post-startup blips. For a local Postgres with an empty volume, 10–15s covers most machines; for CI runners with throttled I/O, 30s is not paranoid.
To see the actual healthcheck output when it fails, inspect the container directly:
docker inspect --format '{{json .State.Health}}' $(docker compose ps -q db) | jq
The Log array holds the stdout and exit code of every check attempt. That output is the fastest way to distinguish “connection refused” from “authentication failed” from “database does not exist” — three different fixes that all present as an unhealthy container.
Node needs its own retry logic because Compose cannot gate on application readiness
Compose gates container startup, not application readiness — a Node process that opens a connection pool at import time can still race a database that passed its healthcheck milliseconds earlier.
The healthcheck tells you Postgres accepts a query. It does not guarantee your specific database, user, or migration state is ready, and it certainly does not guarantee your Node process connects before its first request. For anything beyond a toy project, add a bounded retry loop in the app. With pg (node-postgres) on Node 20+:
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function waitForDb(attempts = 10, delayMs = 1000) {
for (let i = 1; i <= attempts; i++) {
try {
await pool.query('SELECT 1');
return;
} catch (err) {
if (i === attempts) throw err;
console.warn(`db not ready (attempt ${i}/${attempts}), retrying in ${delayMs}ms`);
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
await waitForDb();
This is belt and braces, and it is the right call. The healthcheck prevents the common case; the retry loop covers the race that remains, plus every environment where someone runs your app outside Compose with a database that is slow to start.
The failure mode to watch for: a healthy stack with a dead app
The dangerous outcome is not a crash loop you can see — it is docker compose ps showing every container Up (healthy) while your API returns 500s because it connected once, failed, and its pool never recovered.
Healthchecks prove a container is alive. They prove nothing about your application. Pair the Compose gate with an application-level retry, log the retry attempts so the race is visible in your output, and treat any startup crash that “fixes itself on restart” as an ordering bug until you have ruled it out. Check docker compose version first — on v2.24+ the config above behaves as written, and on the legacy v1 binary none of it does.