Kamol Nazarov The back office lab Brooklyn, NY

Lab notes · N°08 · · 3 min read

How to Make Docker Compose Wait for Postgres with depends_on Healthchecks

depends_on orders starts, not readiness: the long syntax with condition service_healthy, a pg_isready healthcheck, and the compose v1/v2 version gotchas.

Written by
Kamol Nazarov
Published
Tags
docker · compose · postgres
Follow
RSS feed

The note

Saturday deploy, five minutes of quiet, then the alerts: the intake API container was crash-looping on the VPS with ECONNREFUSED 127.0.0.1:5432. The compose file had depends_on right there under the api service, Postgres was listed first, and Postgres was fine � eventually. The app had simply asked too early. If you've searched "docker compose depends_on service_healthy" or "docker compose wait for postgres healthy" after discovering that depends_on lied to you, this is the whole picture: what it actually promises, the two pieces that make it promise more, and the traps that survive the fix.

What depends_on actually promises

Plain depends_on orders container starts, nothing else:

services:
  db:
    image: postgres:17
  api:
    build: .
    depends_on:
      - db   # api starts after db STARTS � not after db is READY

Compose starts db, waits a beat, starts api. But a Postgres container that's "started" is still a second or two from initdb finishing and the port accepting connections � much longer on a fresh volume. The api hits the port mid-boot and gets refused. On my warm dev machine the database was already initialized, so the race never showed; on a cold VPS it showed every time.

The fix is two pieces, not one

condition: service_healthy � the depends_on condition long syntax � only works if the dependency has a healthcheck to evaluate. Miss either half and you're back where you started:

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: intake
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d intake"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

  api:
    build: .
    depends_on:
      db:
        condition: service_healthy

What each piece does:

  • The healthcheck runs pg_isready inside the db container every 5s; start_period: 10s gives first-boot work a grace window where failures don't count; retries: 10 requires sustained failure before the container is flagged unhealthy. Define it here in compose, per project � a HEALTHCHECK in the Dockerfile works too, but compose overrides it if both exist, and per-app details (the user and database name) belong next to the rest of the config.
  • The long depends_on makes the api service wait until that check passes. If you write condition: service_healthy against a service with no healthcheck defined, Compose tells you immediately � that combination is invalid, which is better than the silent version of the mistake.

The pg_isready first-boot trap

pg_isready reports success when the server accepts connections � and on a brand-new volume, Postgres accepts connections, finishes initializing, then restarts itself once. A client can squeeze into that window, connect, and get dropped a second later. The healthcheck narrows the race; it can't delete it. Two defenses:

  • Point the check at your real user and database (-U app -d intake), so success means "the actual database exists," not just "a Postgres answered."
  • Keep a small connect-retry in the app anyway (pg pool with connectionRetryInterval, or ten attempts with a one-second sleep). Gating the start protects you from the boot race; retry protects you from the network existing at all. Kubernetes people learned this the hard way years ago � readiness probes and retries, never one or the other.

The third condition nobody uses

The spec has one more condition worth knowing: service_completed_successfully. It waits for a service to run to a successful exit � perfect for one-shot migration containers that must finish before the app starts:

  migrate:
    build: .
    command: npx prisma migrate deploy
    depends_on:
      db:
        condition: service_healthy

  api:
    build: .
    depends_on:
      migrate:
        condition: service_completed_successfully

For the record, the three conditions and what they guarantee:

Condition Waits until
service_started the container started (same as short syntax)
service_healthy the healthcheck passes
service_completed_successfully the container ran and exited 0

The version gotchas

Two historical footguns still haunt every tutorial written before ~2022:

  • The version: field is dead. Compose v2 (the docker compose plugin, no hyphen) uses the unified Compose Spec and ignores it; you can delete version: "3.9" from the top of the file. Old habits from v1 config files are the main reason people still believe conditions "aren't supported."
  • They genuinely weren't, once. The old v3 file format dropped condition support for Swarm compatibility, which is why half the internet says depends_on conditions don't exist. The Compose Spec brought them back, and docker compose v2 honors them � but if some ancient docker-compose v1 binary runs your file, conditions may error or (worse) be ignored. If you can't drop the hyphen, at least test on the same binary you deploy with.

One sentence to keep: depends_on only ever ordered starts � condition: service_healthy plus a real healthcheck on the dependency is what makes it mean "wait until ready," and an app-side retry is what keeps it meaning that after the race you can't see.