Docker Compose healthcheck depends_on not working: fix startup order for Postgres and Node

Docker Compose healthcheck depends_on not working: fix startup order for Postgres and Node

Your Node container starts, tries to connect to Postgres, and dies with ECONNREFUSED — even though you wrote depends_on: [db]. The fix is a healthcheck plus the long-form depends_on syntax:

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: devpass
      POSTGRES_USER: app
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 2s
      timeout: 3s
      retries: 15
      start_period: 10s

  api:
    build: .
    environment:
      DATABASE_URL: postgres://app:devpass@db:5432/app
    depends_on:
      db:
        condition: service_healthy

Now here is what it actually does, and why the short form you probably wrote first does nothing useful.

Why does depends_on start Postgres and Node in the wrong order?

Plain depends_on only controls container creation order, not readiness — Compose starts the db container, sees it running, and immediately starts your app. Docker has no idea that postgres takes several seconds after process start before it accepts TCP connections on 5432.

The distinction matters:

  • Created — the container exists.
  • Started — the entrypoint process is running.
  • Ready — the service inside is actually answering requests.

Short-form depends_on waits for step two. You need step three. That is the entire bug. It is not a Docker flaw; it is documented behaviour in the Compose specification, and it has been stable across Compose v2 releases through 2026.

This is the failure mode that costs people hours: the app works on a fast laptop with a warm Postgres volume, then fails in CI on every fresh run, and the “fix” becomes a sleep 10 in the entrypoint. Do not do this. Do this instead.

What does condition: service_healthy actually wait for?

condition: service_healthy blocks the dependent container from starting until the target container’s healthcheck reports healthy at least once. Compose polls the healthcheck on your configured interval, and only then creates and starts the dependent service.

The four conditions available in Compose v2 are:

  • service_started — same as the short form. Useless for databases.
  • service_healthy — waits for the healthcheck to pass. This is what you want.
  • service_completed_successfully — waits for a one-shot container (migrations, seed scripts) to exit 0.
  • service_restarting — niche; waits for the service to enter a restarting state.

Note the ordering requirement: the healthcheck must be defined on the dependency, not on the app. A healthcheck on your Node service does not help it wait for anything.

Which healthcheck command should you use for Postgres in 2026?

Use pg_isready, which ships inside the official Postgres image and returns exit code 0 only when the server is accepting connections. It is the correct tool; nc -z localhost 5432 is a worse substitute because the port can be open before the server finishes its own startup sequence.

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U app -d app"]
  interval: 2s
  timeout: 3s
  retries: 15
  start_period: 10s

Two details people get wrong:

  • Pass -U and -d. Without a user, pg_isready defaults to the invoking OS user and can report ready against a server that will reject your app’s credentials.
  • Use CMD-SHELL, not CMD, when you need shell expansion or environment variables. For a plain command like this either works, but CMD-SHELL is the safer default and matches what most teams copy around.

The start_period is the part that saves you: failures during that window do not count toward retries. Set it to roughly your worst-case cold start. On a first-boot Postgres with initdb running, that is commonly 5–15 seconds depending on disk. Ten seconds is a reasonable default; raise it on slow CI runners.

Why does the healthcheck pass but your Node app still crashes?

Because pg_isready confirms the server accepts connections — it does not confirm your database, user, or migrations exist. A healthy Postgres with an empty schema will happily accept a connection and then fail your first query.

If your app runs migrations at boot, split that into a one-shot service and gate the app on it:

  migrate:
    build: .
    command: ["node", "scripts/migrate.js"]
    environment:
      DATABASE_URL: postgres://app:devpass@db:5432/app
    depends_on:
      db:
        condition: service_healthy

  api:
    build: .
    environment:
      DATABASE_URL: postgres://app:devpass@db:5432/app
    depends_on:
      migrate:
        condition: service_completed_successfully

This is the pattern that removes boot-order flakiness entirely, because service_completed_successfully waits for exit code 0. If the migration fails, the app never starts and you get the real error instead of a connection-refused red herring.

One caveat: service_completed_successfully is not honoured on every Compose implementation. It is supported in Docker Compose v2 (the docker compose plugin) but historically was ignored by some third-party Compose-compatible runtimes. Verify on your own stack before relying on it in CI.

How do you debug a healthcheck that never turns healthy?

Inspect the container’s health log directly with docker inspect, which prints the last few healthcheck outputs and their exit codes. Guessing from application logs wastes time; the health log tells you exactly what the probe returned.

docker compose up -d db
docker inspect --format '{{json .State.Health}}' $(docker compose ps -q db) | jq

Common outputs and what they mean:

  • no such user: app — your POSTGRES_USER does not match the -U flag in the probe.
  • connection refused — Postgres is still in initdb; increase start_period.
  • Repeated starting with no failure text — the probe command itself is malformed (usually a quoting problem in the YAML list form).

Two more things worth knowing:

  • Healthchecks do not run on a container that has exited. If Postgres crashes during initdb, the health status stays starting and your dependent service waits forever. Always check docker compose ps for exit codes alongside health.
  • A stale volume can mask the bug. Postgres only runs initdb on an empty data directory. If your healthcheck passes instantly on your machine and fails on a colleague’s, the difference is usually a pre-existing named volume. Test with docker compose down -v before you conclude the config is correct.

Should you still add retry logic inside the Node app?

Yes — healthcheck gating fixes startup order, but it does not survive a Postgres restart mid-run. A healthcheck is a one-time gate at container start; it gives you nothing if the database is restarted, fails over, or briefly drops connections later.

For a Node app using pg, wrap the initial connection in a bounded retry:

import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function connectWithRetry(attempts = 10, delayMs = 1000) {
  for (let i = 1; i <= attempts; i++) {
    try {
      const client = await pool.connect();
      await client.query('SELECT 1');
      client.release();
      return;
    } catch (err) {
      if (i === attempts) throw err;
      console.warn(`db not ready (attempt ${i}/${attempts}): ${err.message}`);
      await new Promise((r) => setTimeout(r, delayMs));
    }
  }
}

Call that once during boot. It is cheap insurance and it means a slow database degrades into a delayed start rather than a crash loop. Note that pg will not reconnect a broken pool automatically in all failure modes — for long-running services, handle the pool’s error event rather than assuming the connection is permanent.

The bottom line

Short-form depends_on orders container creation; it does not wait for readiness. Replace it with the long-form condition: service_healthy, put a pg_isready -U <user> -d <db> probe on the Postgres service with a start_period that covers cold initdb, and gate migrations behind service_completed_successfully if you run them at boot.

The failure mode to watch for: a healthcheck that passes on your machine because a named volume already exists, then fails on every clean CI run. Run docker compose down -v && docker compose up before you trust the config — an empty data directory is the only honest test of startup ordering.

Related Articles

Similar Posts

Leave a Reply

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