DEV Community

Cover image for Healthchecks and depends_on in Docker Compose: why your API starts before Postgres
Jhoan Sebastian Rojas Holguin
Jhoan Sebastian Rojas Holguin

Posted on AI-assisted

Healthchecks and depends_on in Docker Compose: why your API starts before Postgres

Your API isn't broken: it just arrived at the party before Postgres. It's a classic failure in local environments and in CI, and it's fixed with about 6 net lines of YAML. I'll be direct: reproduce, fix, and measure.

🐛 The problem

depends_on in its short form only guarantees the startup order of containers. Compose starts db, sees that the process exists, and starts api. Whether Postgres is still initializing the cluster or replaying WAL doesn't matter to it.

Result: your API tries to connect, gets Connection refused or the database system is starting up, and crashes.

🧪 Step 1: reproduce it

So it can be verified without depending on your app, I use the Postgres image as a client. The "api" runs a single query and exits.

# compose.yaml (versión con el fallo)
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app

  api:
    image: postgres:16
    depends_on:
      - db
    command: ["psql", "postgresql://postgres:secret@db:5432/app", "-c", "select 1"]
Enter fullscreen mode Exit fullscreen mode

Run it from scratch, without previous volumes, and capture the exit code:

docker compose down -v
time docker compose up --abort-on-container-exit --exit-code-from api
echo "exit code: $?"
Enter fullscreen mode Exit fullscreen mode

On a clean volume, Postgres takes a few seconds to run initdb. Expect something like connection to server at "db" ... failed: Connection refused and a non-zero exit code. It's a timing race, so on a very fast machine or with an already-initialized volume it might not happen; that's why we use down -v.

🔧 Step 2: healthcheck + condition

Two changes are needed: db must report when it's ready, and api must wait for that signal.

# compose.yaml (versión corregida)
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres -d app"]
      interval: 2s
      timeout: 3s
      retries: 15

  api:
    image: postgres:16
    depends_on:
      db:
        condition: service_healthy
    command: ["psql", "postgresql://postgres:secret@db:5432/app", "-c", "select 1"]
Enter fullscreen mode Exit fullscreen mode

Net lines: 5 from the healthcheck block plus 1 for changing depends_on from a list to a map with condition. Those are the 6.

⚠️ The -h 127.0.0.1 trap

On first startup, the official Postgres image starts a temporary server to run the scripts in /docker-entrypoint-initdb.d. That temporary server listens only on a Unix socket, not over TCP. If your healthcheck uses pg_isready without -h, it queries that socket and can report "healthy" just before Postgres restarts to open the real port.

Forcing -h 127.0.0.1 makes the check go over TCP, so it only turns green once the final server is accepting connections, which is what your API needs.

📏 Step 3: measure before and after

Run the same command with both files (-f compose.yaml) and note:

docker compose down -v
/usr/bin/time -f "%e s" docker compose up --abort-on-container-exit --exit-code-from api
echo "exit code: $?"
docker compose logs api | grep -ci "refused\|starting up"
Enter fullscreen mode Exit fullscreen mode

This measures three things: total time, exit code, and the number of connection errors in the log. My measurements in this test:

Version Time until api OK Connection errors Exit code

Without a healthcheck, Compose considers the database ready as soon as its container starts, so the api tries to connect too early and usually leaves "refused" errors in the log.

What you should see with the corrected version: 0 connection errors and exit code 0. The total time may be a bit longer than the failed attempt, because now you actually wait; what changes is that the result stops being a lottery.

🔁 Why it matters in CI

In a pipeline, startup is slower and less predictable than on your laptop. A sleep 10 is a patch: either there's time to spare or there isn't enough. The healthcheck waits just long enough and fails with a clear message if Postgres never becomes ready (after retries attempts), instead of leaving the API restarting in a loop. In my work with PostgreSQL and GitHub Actions deployments, I prefer that the dependency declares its state rather than having each service guess.

✅ Result

  • A simple depends_on = startup order, not availability.
  • healthcheck on the database + condition: service_healthy on the API = the API starts when Postgres already responds over TCP.
  • Verify with --exit-code-from api and count the errors in the log; don't rely on "it works on my machine".

You still should give your app retries with backoff for production outages: the healthcheck solves coordinated startup, not a crash mid-flight.

Top comments (0)