Docker Compose healthcheck depends_on condition not working: why your container starts before Postgres is ready and the exact compose.yaml fix

Docker Compose healthcheck depends_on condition not working: why your container starts before Postgres is ready and the exact compose.yaml fix

Your app container starts before Postgres finishes booting, throws ECONNREFUSED, and exits — even though you wrote depends_on with a healthcheck condition. The fix is to make the healthcheck actually report unhealthy until the database accepts connections, then gate startup on it:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s

  app:
    build: .
    depends_on:
      db:
        condition: service_healthy

That block works on Docker Compose v2.20+ (the docker compose plugin, not the legacy docker-compose v1 binary). If it still fails, the cause is almost always one of the four issues below.

Why the condition is ignored: you are probably running Compose v1

Compose v1 (docker-compose, the Python implementation) ignores the long-form depends_on map and silently falls back to plain start-order. That is the single most common reason a correct-looking condition: service_healthy does nothing.

Check what you have:

docker compose version
# Docker Compose version v2.29.7  <-- good, plugin form

docker-compose version
# docker-compose version 1.29.2  <-- legacy, ignores condition

If both exist, use the space-separated plugin form (docker compose up) everywhere — in your Makefile, CI workflow, and README. The hyphenated v1 binary reached end of life in July 2023 per Docker’s own Compose v1 deprecation notice, so scripts still calling it on a modern host are running against a tool that no longer receives fixes.

A healthcheck that passes too early is worse than no healthcheck

The default Postgres image ships no healthcheck, so a container with none is treated as healthy the instant it is running — which is roughly 2–10 seconds before the initdb and startup sequence completes. The gate opens on the wrong signal.

Two rules make the check honest:

  • Test the thing you actually need. pg_isready returns 0 only when the server accepts connections. Do not use test: ["CMD", "true"] or a bare ps check — those report healthy on a half-started server.
  • Set start_period. Failures during this window do not count against retries. Without it, a slow first boot on a cold volume can exhaust your retries before initdb finishes.

Here is the failure mode that costs people hours: the healthcheck is correct, but interval is 30s and retries is 3. The container is healthy at second 8, but Compose does not re-poll until second 30, so your app waits 22 extra seconds for no reason. Drop interval to 5s while you are debugging, then tune it.

depends_on only gates startup — it does not restart your app when the database dies

condition: service_healthy guarantees ordering at up time and nothing after that. If Postgres crashes and restarts mid-run, your app is not restarted and will keep failing until you intervene.

Do not assume depends_on gives you resilience. It does not. For that you need either a restart policy plus retry logic in the app, or an orchestrator (Kubernetes, Nomad, ECS) with real readiness probes. For local development, a retry loop in the app is usually enough:

# entrypoint.sh — retry until the DB answers, then exec the real process
until pg_isready -h db -U postgres -d postgres; do
  echo "waiting for postgres..."
  sleep 1
done
exec "$@"

The retry loop is not redundant with the healthcheck. It covers the crash-and-restart case that depends_on cannot see.

The exact compose.yaml that survives a cold start and a mid-run restart

The following file is what to copy when you want both ordering and recovery. It uses service_healthy for startup ordering and a restart policy for the crash case.

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 15s
    restart: unless-stopped

  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
        restart: true          # Compose v2.20+ — restart app if db restarts
    restart: unless-stopped

volumes:
  pgdata:

The restart: true key under depends_on is the one people miss. It tells Compose to restart the dependent service when the dependency restarts — added in Compose v2.20.0 (Docker release notes, 2024). Without it, a Postgres restart leaves your app pointing at a dead socket.

Common failure modes and how to read them

The container is “healthy” but the app still gets connection refused

This happens when the healthcheck command targets the wrong user or database. pg_isready -U postgres succeeds against the default postgres database even if your app connects to appdb with user app. Match the healthcheck to the credentials in your app’s connection string.

Compose reports the dependency as unhealthy and aborts

Read the actual healthcheck output — do not guess:

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

The Log array shows every probe and its exit code. An exit code of 2 from pg_isready means “no response”; 1 means “rejecting connections”; 0 means ready. If you see 1 repeatedly, the server is up but still in recovery or rejecting your user — usually a volume permission problem on a bind mount.

It works on macOS but not on Linux CI

Bind-mounted Postgres data directories hit permission and fsync differences between Docker Desktop’s VM and native Linux. Named volumes (as in the file above) behave consistently across both. If you must bind-mount, ensure the host directory is owned by the Postgres UID (999 in the official image).

What to watch for after you ship this

The failure mode to watch for: your healthcheck interval is tuned for a fast local SSD, then someone runs the same compose file on a slower CI runner or a laptop under load. The start_period expires, retries run out, and the stack aborts with “dependency failed to start: container is unhealthy.” Set start_period generously (15–30s for Postgres) and keep interval short — the two knobs serve different purposes and confusing them is what makes a working config look broken on a different machine.

Related Articles

Similar Posts

Leave a Reply

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