Docker Compose healthcheck depends_on Condition Not Working: Why Your Container Starts Before Postgres Is Ready in 2026

Docker Compose healthcheck depends_on Condition Not Working: Why Your Container Starts Before Postgres Is Ready in 2026

Your app container crashes with ECONNREFUSED 127.0.0.1:5432 even though you set depends_on: postgres: condition: service_healthy, and the fix is almost always one of three things: you are on a Compose file format older than 2.1, your healthcheck block is missing entirely, or you put the healthcheck on the wrong service. The one-liner that tells you which:

docker compose config | grep -A3 -i "depends_on\|healthcheck"

That command renders the fully resolved Compose model after all overrides and .env substitution. If condition: service_healthy does not appear in the output, Compose has silently downgraded your dependency to plain start-order and your app will race Postgres every single time. Now here is what it actually does and how to make it stop.

The verdict table: what each depends_on condition actually guarantees

Only condition: service_healthy waits for a passing healthcheck; service_started waits for nothing more than process creation, and service_completed_successfully waits for a one-shot container to exit 0. That distinction is the entire bug. Compose v2 (the Go plugin, current as of 2026) supports all three; the legacy Python docker-compose v1 binary does not support conditions at all.

Condition Waits for Fails when Use it for
service_started Container created and started Target isn’t accepting connections yet Stateless deps with no readiness concept
service_healthy A healthcheck on the target returning 0 No healthcheck defined, or it never passes Postgres, MySQL, Redis, Kafka, anything with a listener
service_completed_successfully Target exits with status 0 Target exits non-zero or hangs Migrations, seed scripts, init jobs

The failure mode that costs people hours: depends_on with service_healthy on a service that has no healthcheck. Compose v2 does not error. It treats the service as started and moves on. You get the race you were trying to prevent, plus a config that looks correct in review.

A working compose.yaml that actually waits for Postgres

This file is known to work on Docker Compose v2.29+ and Docker Engine 27.x on Linux, macOS, and Windows with the WSL2 backend. Copy it, change the credentials, and it will gate the API on a real pg_isready probe rather than a TCP guess.

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

  api:
    build: .
    depends_on:
      postgres:
        condition: service_healthy
        restart: true
    environment:
      DATABASE_URL: postgres://app:app@postgres:5432/app

volumes:
  pgdata:

Three details carry the weight here. First, pg_isready is shipped inside the official Postgres image, so you do not need curl or nc installed. Second, start_period: 10s gives the initdb phase room to finish before failed probes count against retries — on a cold volume, Postgres genuinely needs several seconds. Third, restart: true (Compose v2.17+) means that if Postgres is later recreated by docker compose up, the API restarts too, instead of holding a stale connection to a dead container IP.

Why the healthcheck “works” but the dependency still doesn’t

A healthcheck that passes before the database accepts connections is worse than no healthcheck, because it converts a visible race into a silent one. The classic offender is a TCP-only probe:

# Do not do this. It passes as soon as the socket is bound.
healthcheck:
  test: ["CMD", "nc", "-z", "localhost", "5432"]

Postgres binds the socket well before it finishes recovery and starts answering queries. A TCP check goes green in milliseconds and your app connects into a database that will reject it. Do this instead — probe the actual protocol with credentials:

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

The same pattern applies elsewhere: for Redis, redis-cli ping; for MySQL, mysqladmin ping -h 127.0.0.1 -u root -p"$MYSQL_ROOT_PASSWORD"; for an HTTP service, wget -qO- http://localhost:8080/healthz || exit 1. The probe should fail until the service can do real work, not merely until the process exists.

There is a documented gotcha in the official Postgres image notes: during the first initialization, the temporary server started by the entrypoint listens on the Unix socket only, not on TCP. A healthcheck using pg_isready -h 127.0.0.1 therefore stays red through initdb — which is exactly what you want, because your app should not be connecting during initdb either.

Compose v1 versus v2: the silent downgrade that breaks depends_on

Compose file format versions before 2.1 do not support condition at all, and the legacy docker-compose v1 Python tool ignores it even in newer formats. If your file starts with version: "2" or version: "3", delete the version key entirely — the Compose Specification (adopted by Docker in 2020 and the default in every v2 release since) does not need it and warns on it.

# Check which binary and version you are actually running
docker compose version
# Docker Compose version v2.29.7

docker-compose version 2>/dev/null || echo "v1 not installed (good)"

If the first command errors, you have the v1 binary only. Install the Compose plugin per the official Docker docs for your platform — on Debian/Ubuntu that is sudo apt-get install docker-compose-plugin from Docker’s own apt repository, not the distro’s docker-compose package. The two tools read the same file but behave differently, which is why the bug reproduces on one machine and not another.

What to watch for after you fix it

The remaining failure mode is a healthcheck that passes forever after one good probe, hiding a dependency that died later. depends_on is a startup gate, not a supervisor — once the API is running, Compose will not restart it because Postgres went unhealthy. Pair the gate with a restart policy (restart: unless-stopped) and, for anything long-lived, an application-level reconnect/backoff in your database client. Measure your own startup timing with docker compose up and watch the Healthy transition in docker compose ps; if Postgres reports healthy in under a second on a cold volume, your probe is checking the wrong thing.

Related Articles

Similar Posts

Leave a Reply

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