Docker Compose healthcheck depends_on condition not working: fix for Postgres and Node services

Docker Compose healthcheck depends_on condition not working: fix for Postgres and Node services

Your Node service starts before Postgres is ready, crashes with ECONNREFUSED, and Compose brings the whole stack down. The one-liner that usually fixes it:

services:
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s
  api:
    build: .
    depends_on:
      db:
        condition: service_healthy

Now here is what it actually does, and why the version you already wrote probably does nothing.

The real cause is almost always a missing or broken healthcheck on the dependency

condition: service_healthy does not wait for Postgres to accept connections. It waits for the db container’s health status to become healthy, and if the db service has no healthcheck block, Compose treats the condition as unsatisfiable and refuses to start the dependent service at all.

Two failure modes produce the same “it doesn’t work” symptom:

  • No healthcheck defined on the dependency. Compose errors out with something like dependency failed to start: container ... has no healthcheck configured. People often “fix” this by deleting the condition: line, which silently reverts to plain start-order and reintroduces the race.
  • A healthcheck that passes too early, e.g. test: ["CMD", "true"] or a pg_isready that succeeds against a Postgres still initialising its data directory on first boot. The container flips to healthy, Node starts, and the connection is refused anyway.

Compose’s depends_on long syntax with condition has been supported since Compose file format 2.1 and remains the documented behaviour in the Compose Specification used by Docker Compose v2 (Docker docs, Compose Specification, 2026). There is no hidden magic: the condition is evaluated against the container’s health state, nothing else.

Why pg_isready alone is not a sufficient readiness signal for Postgres

pg_isready returns success when the server is accepting connections on the socket, which on a fresh volume can happen while the entrypoint is still running init scripts and the appdb database does not yet exist. A Node app that connects and immediately runs a migration will fail even though the healthcheck passed.

This is the failure mode that costs people hours: the logs show database "appdb" does not exist on the very first docker compose up, then everything works on the second run because the volume is now initialised. That asymmetry is the tell.

Make the healthcheck query the actual database you need:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres -d appdb && psql -U postgres -d appdb -c 'SELECT 1' >/dev/null 2>&1"]
  interval: 5s
  timeout: 5s
  retries: 12
  start_period: 15s

The psql -c 'SELECT 1' only succeeds once the target database exists and accepts a query. The start_period tells Docker to ignore failures during the first 15 seconds, so a slow first-boot init is not counted against retries. Without start_period, a container that takes 40 seconds to initialise with a 5s interval and 10 retries will be marked unhealthy at the 50-second mark even though it is working fine.

The Node side needs its own retry loop, because healthchecks are not a transaction

A healthcheck is a poll, not a promise. Between the moment Postgres reports healthy and the moment your Node process opens a connection, the database can restart, the pooler can drop, or the container can be recreated. Relying on depends_on as your only guard is the mistake.

Add a bounded retry at startup. Plain pg in Node 20+:

import pg from "pg";

const MAX_ATTEMPTS = 20;
const DELAY_MS = 1500;

async function connectWithRetry() {
  const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    try {
      const client = await pool.connect();
      await client.query("SELECT 1");
      client.release();
      return pool;
    } catch (err) {
      console.error(`db not ready (attempt ${attempt}/${MAX_ATTEMPTS}):`, err.code);
      if (attempt === MAX_ATTEMPTS) throw err;
      await new Promise((r) => setTimeout(r, DELAY_MS));
    }
  }
}

const pool = await connectWithRetry();

Twenty attempts at 1.5s covers 30 seconds of unavailability, which is generous for a local stack and still fails fast in CI. Log the err.code (ECONNREFUSED, 57P03, 3D000) so the difference between “not listening” and “database missing” is visible in docker compose logs api.

You can verify the condition is actually being evaluated with docker compose ps

The quickest diagnostic is to watch health status transition while the stack starts. In a second terminal, run:

docker compose up -d db && watch -n1 'docker compose ps --format "table {{.Name}}\t{{.Status}}"'

On Docker Compose v2.24+ (the current v2 line as of 2026), the status column shows Up 3s (health: starting) then Up 12s (healthy). If you never see health: starting, the healthcheck is not being applied at all, which usually means it is indented under the wrong key or the file was not the one Compose loaded. Confirm which file is in play with docker compose config, which prints the fully merged and interpolated configuration.

A second check: docker inspect --format '{{json .State.Health}}' $(docker compose ps -q db) returns the raw health log, including each probe’s exit code and output. That is the ground truth; depends_on reads exactly this state.

Common configuration mistakes that silently disable the condition

Most broken setups differ from the working one by one of these:

  • Mixing the short and long syntax. depends_on: [db] is the short form and cannot express a condition. You need the mapping form with condition:.
  • Using service_started and expecting readiness. service_started only waits for the container to be created and started, not for the process inside to be useful.
  • Quoting the test as a single string incorrectly. test: "pg_isready -U postgres" runs the whole string as one executable. Use the list form with CMD-SHELL when you need shell semantics like &&.
  • Forgetting restart: unless-stopped on the API. If the app still exits during a slow start, the container stays dead and depends_on has nothing to do with it.
  • Overriding the compose file. A docker-compose.override.yml with a bare depends_on: [db] will replace the long form in the base file. Run docker compose config to see the merge result.

Do not do this: depends_on: [db] plus a sleep 10 in your entrypoint. Do this instead: a real healthcheck on db, condition: service_healthy on api, and a bounded retry loop in the Node process.

The failure mode to watch for after you fix it

The remaining trap is a healthcheck that is too strict and marks a working container unhealthy under load, which then blocks every dependent service from starting and makes docker compose up hang with no obvious error. Keep timeout comfortably above your p99 query time, set start_period to cover first-boot initialisation, and prefer a single cheap probe (SELECT 1) over anything that scans tables. If a service is stuck in health: starting, run docker inspect on it before changing anything else: the probe output tells you whether the database is slow or the check is wrong.

Related Articles

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *