Docker Compose healthcheck depends_on condition not working: fixing startup order for Postgres and Node.js in 2026
Docker Compose healthcheck depends_on condition not working: fixing startup order for Postgres and Node.js in 2026
Your Node.js container crashes with ECONNREFUSED 127.0.0.1:5432 even though depends_on lists Postgres and Compose says both started. The fix is to combine depends_on with condition: service_healthy and a real healthcheck on the database — depends_on alone only waits for the container to be created, not for Postgres to accept connections.
The one-liner that actually works:
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
api:
build: .
depends_on:
db:
condition: service_healthy
Now here is what it actually does, why the naive version fails, and the failure modes that still bite you after you add it.
Why depends_on alone does not wait for Postgres to be ready
Plain depends_on controls container start order, not application readiness. Compose starts the db container first, then immediately starts api — even if Postgres is still initializing its data directory and hasn’t opened port 5432 yet.
This is the failure mode that costs people hours: the Node process boots in under a second, tries to connect, gets refused, and exits. Restarting the container manually “fixes” it because by then Postgres is up. That makes it look like a flaky bug rather than an ordering bug.
The condition: service_healthy form changes the semantics. Compose will not start api until the db healthcheck reports healthy. That is the difference between “the container exists” and “the service answers.”
One environment note: the long-form depends_on with condition is supported in the Compose Specification, which Docker Compose v2 (the docker compose plugin, not the old docker-compose v1 binary) implements. If you are on the legacy v1 Python binary, condition was removed in version 3 of the file format and you will get a validation error. Check with docker compose version — you want v2.x.
What you need before you start
- Docker Engine 24+ with the Compose v2 plugin (
docker compose versionshould print v2.x). - A project with a
Dockerfilefor the Node service and acompose.yaml(ordocker-compose.yml). - Postgres 16 or 17 as the database image — the examples below use
postgres:16-alpine. - Your Node app reading connection settings from environment variables, not hardcoded
localhost.
Step 1: Add a healthcheck that tests the database, not the container
A healthcheck must run a command inside the container that fails until the service is genuinely ready. For Postgres, that command is pg_isready, which ships with the official image.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: appdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
volumes:
pgdata:
Each field matters:
interval: 5s— how often the check runs. Five seconds keeps startup fast without hammering the DB.timeout: 5s— a hung check counts as a failure instead of blocking forever.retries: 10— ten consecutive failures before the container is markedunhealthy.start_period: 10s— failures during this window do not count againstretries. This is the field people forget, and it is why a slow first-time initdb can still trip the check.
Do not use test: ["CMD", "pg_isready"] without flags if your username or database name differs from the defaults — pg_isready will connect as the wrong role and can report ready before your app’s database exists. Pass -U and -d explicitly.
Step 2: Gate the Node service on the healthy condition
Replace the short-form depends_on list with the long form and set condition: service_healthy.
api:
build: .
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/appdb
NODE_ENV: production
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
restart: true
Two details worth calling out:
- The hostname is
db— the service name — notlocalhost. Inside the Compose network,localhostis the Node container itself. This is the single most common cause ofECONNREFUSEDthat survives the healthcheck fix. restart: true(Compose v2.17+) tells Compose to restart the dependent service if the database is restarted or recreated. Without it, adocker compose restart dbleaves your API pointing at a dead connection pool.
Bring it up and watch the ordering:
docker compose up --build
You should see Compose wait on db until the healthcheck passes, then start api. Verify the state directly:
docker compose ps --format "table {{.Service}}\t{{.Status}}"
The db row should read Up (healthy) before api appears as running.
Step 3: Make the Node app fail loudly instead of silently
Even with correct ordering, the app should not assume the database is reachable forever. A healthcheck only guarantees readiness at start — Postgres can restart, a network blip can drop connections, and a connection pool can go stale.
Add a bounded retry at boot so the process survives a brief race, and log the failure instead of exiting silently:
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');
console.log('database reachable');
return;
} catch (err) {
console.warn(`db not ready (attempt ${i}/${attempts}):`, err.message);
if (i === attempts) throw err;
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
await waitForDb();
This is defense in depth, not a replacement for the healthcheck. The healthcheck handles the common case; the retry handles the edge cases the healthcheck cannot see.
Common mistakes that keep the ordering broken
These are the configurations that still fail after someone adds condition: service_healthy:
- No healthcheck on the database. If
dbhas nohealthcheckblock,condition: service_healthyhas nothing to wait on and Compose errors out or starts immediately depending on version. The healthcheck is mandatory for the condition to mean anything. - Using
sleepin the Node entrypoint.command: sh -c "sleep 10 && node server.js"is a race condition with extra steps. It works on a fast laptop and fails on a cold CI runner. Remove it. - Mounting a fresh volume over an initialized one. A named volume that persists old data can skip initdb, making startup fast in dev and slow in CI — so the bug only appears in one environment. Test with
docker compose down -vat least once. - Healthcheck that always passes. A check like
test: ["CMD", "true"]or one that greps a log line will report healthy before the port is open. The check must exercise the actual service. - Relying on
depends_onacross restarts.depends_ononly applies at initial startup. Ifdbrestarts later,apiis not restarted unless you setrestart: trueon the dependency.
What to watch for after the fix is in place
The failure mode that remains is a healthcheck that is correct at boot but wrong at runtime. pg_isready confirms the server accepts connections — it does not confirm your application’s schema migrations have run, nor that the database is not in recovery. If your Node service runs migrations at startup, gate the API on a dedicated one-shot migration service instead, and have the API depend on that service completing successfully.
For context on how common this class of bug is: the official Postgres Docker image documentation has long warned that the container is ready to accept connections only after initdb finishes (Postgres Docker Hub docs, 2026), and Docker’s own Compose documentation states that depends_on “does not wait for the services to be ready” without a health condition (Docker Docs, 2026). Those two facts together are the entire bug.
Measure your own startup timing with docker compose up and watch the timestamps — if api logs appear before db reports database system is ready to accept connections, your condition is not wired up. The bottom line: a healthcheck is only as good as the command inside it, and depends_on is only as good as the condition attached to it.