Home/Infrastructure/When docker compose depends_on Doesn't Wait for Your DB — Fix connection refused with healthcheck
InfrastructureDocker Composehealthcheck

When docker compose depends_on Doesn't Wait for Your DB — Fix connection refused with healthcheck

Does your app still die with connection refused even with depends_on? Here's why depends_on only guarantees start order, and how to wait until the service is actually ready with healthcheck + condition: service_healthy — copy-paste recipes

When docker compose depends_on Doesn't Wait for Your DB — Fix connection refused with healthcheck
Contents
  1. "I set depends_on — why is it still dying?" — the classic failure scenario
  2. Mental model: depends_on only guarantees start order
  3. The fix, part 1 — four copy-paste healthcheck recipes
  4. The fix, part 2 — depends_on long syntax + condition
  5. When you can't use healthcheck — comparing alternatives
  6. Common mistakes FAQ
  7. See also: official docs
  8. FAQ
  9. Wrap-up — practical checklist and a final template

Why docker compose depends_on doesn't wait for the DB — ending connection refused with healthcheck

Ever run docker compose up to bring up your app and DB together, only to watch the app container die with connection refused? "I clearly set depends_on: [db] — why didn't it wait for the DB?" This post fixes that mental model and gives you copy-paste healthcheck + condition recipes for Postgres, MySQL, Redis, and HTTP so you can kill the problem at the root.

"I set depends_on — why is it still dying?" — the classic failure scenario

The most common form looks like this.

YAML
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
  api:
    build: .
    depends_on:
 - db # ← when " "

When you up, the console prints logs like this and the app exits.

TEXT
api-1  | could not connect to server: Connection refused
api-1  |   Is the server running on host "db" (172.18.0.2) and accepting
api-1  |   TCP/IP connections on port 5432?
api-1  | Error: dial tcp 172.18.0.2:5432: connect: connection refused
api-1 exited with code 1

The db container is clearly up, but the Postgres process inside hasn't finished initializing yet (data directory creation, WAL prep), so it isn't accepting connections on port 5432. The app can't survive that 0.5–3 second gap and dies.

Mental model: depends_on only guarantees start order

The one-liner: depends_on only controls container start order. It knows nothing about whether the process inside the container is ready.

TEXT
[ depends_on ]
db created → started ──┐
 ├─→ api started ( !)
                               │
db Postgres boot ...... ─┘ ← = connection refused

In other words, "the container started" ≠ "the DB is ready to accept connections (healthy)". Default short-syntax depends_on only waits until started, so it's useless in front of a DB or message queue that needs readiness. To fix this you need to (1) define a healthcheck that decides whether the service is healthy, and (2) make depends_on wait for that signal with condition: service_healthy.

The fix, part 1 — four copy-paste healthcheck recipes

First, the healthcheck options:

OptionMeaningRecommended (for DBs)
testHealth probe command. Exit code 0 means healthyPer-service ping command
intervalHow often to check5s
timeoutTime limit for a single check5s
retriesConsecutive failures before unhealthy5
start_periodBoot grace period (failures inside this window don't count)10s–30s

Tip: CMD-SHELL runs through a shell, so || and env var expansion work. CMD execs without a shell. To avoid colliding with Compose env vars, escape variables that should be evaluated inside the container as $$.

Postgres

YAML
db:
  image: postgres:16
  environment:
    POSTGRES_USER: app
    POSTGRES_PASSWORD: secret
    POSTGRES_DB: appdb
  healthcheck:
 # $$ → compose
    test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
    interval: 5s
    timeout: 5s
    retries: 5
    start_period: 10s

MySQL

YAML
mysql:
  image: mysql:8
  environment:
    MYSQL_ROOT_PASSWORD: secret
    MYSQL_DATABASE: appdb
  healthcheck:
    test: ["CMD-SHELL", "mysqladmin ping -h localhost -p$$MYSQL_ROOT_PASSWORD"]
    interval: 5s
    timeout: 5s
    retries: 10
 start_period: 30s # MySQL

Redis

YAML
redis:
  image: redis:7
  healthcheck:
 # PONG
    test: ["CMD", "redis-cli", "ping"]
    interval: 5s
    timeout: 3s
    retries: 5

HTTP app (its own /health endpoint)

YAML
web:
  build: .
  healthcheck:
 # curl alpine wget -qO-
    test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
    interval: 10s
    timeout: 5s
    retries: 3
    start_period: 15s

The fix, part 2 — depends_on long syntax + condition

Once you've defined the healthcheck, declare the condition on the dependent side with long syntax.

YAML
services:
  api:
    build: .
    depends_on:
      db:
 condition: service_healthy # db healthy whento
      migrate:
 condition: service_completed_successfully # 0 whento
      redis:
 condition: service_started # OK

Here's how the three conditions differ.

conditionWhat it waits forWhen to use it
service_startedContainer startSidecar services that don't need readiness
service_healthyhealthcheck reports healthyDBs, queues, anything you connect to
service_completed_successfullyExits with code 0DB migration / seed jobs

Version note: The condition syntax is part of the Compose Spec and works correctly with Docker Compose v2 (the docker compose plugin). Older version: "3.x" schemas with short-syntax (depends_on: [db]) ignored condition. These days the version key itself is deprecated, so drop it and standardize on current docker compose.

When you can't use healthcheck — comparing alternatives

Sometimes the image has no ping tool, or you can't add a healthcheck. Here are your options.

ApproachHow it worksProsCons / when to use
wait-for-it.shPoll until the TCP port is open, then run the commandNo extra deps, simpleOnly sees the port, not "ready for queries". Fine for light waits
dockerize -waitWait on port/HTTP + templatesSupports both TCP and HTTPNeed to add a binary. For waiting on multiple deps
App-level retry (backoff)The app reconnects itselfInfra-independent, most robustRequires code. Best practice

App-level backoff example (Node.js):

JavaScript
async function connectWithRetry(retries = 10, delay = 1000) {
  for (let i = 0; i < retries; i++) {
    try { return await db.connect(); }
    catch (e) {
 console.warn(`DB failed, when ${i + 1}/${retries}`);
 await new Promise(r => setTimeout(r, delay * 2 ** i)); //
    }
  }
 throw new Error("DB failed");
}

In practice, healthcheck + condition kills about 90% of boot-timing issues, but it can't cover a DB that briefly drops and recovers in production (rolling updates, network blips). From a 12-factor / cloud-native angle, healthcheck is a recommended accelerator; app-level retry is the required safety net. They complement each other, they don't compete.

Common mistakes FAQ

See also: official docs

The primary source for the behavior, config, and errors in this post is the official docs below. Check there for version-specific options and exact semantics.

FAQ

Q. I set condition: service_healthy but it still goes unhealthy and the app never starts. A. Almost always start_period is too short. For images with long init like MySQL, bump it to start_period: 30s or more. Failures inside that window don't count toward retries, so transient boot failures get ignored.

Q. The healthcheck test keeps failing. The command looks right. A. (1) If you use shell syntax (||, variables) with CMD, it fails → switch to CMD-SHELL. (2) If you write Compose variables as $, Compose interpolates them first → escape variables that should be evaluated in the container as $$. (3) Alpine images often don't have curl, so use wget -qO- instead.

Q. db is healthy but the app still dies occasionally. A. healthcheck only solves boot timing. Transient disconnects in production need app-level retry (exponential backoff). Also pair it with a restart policy like restart: unless-stopped, but watch that you don't pile up logs from infinite restarts while the healthcheck is unhealthy.

Wrap-up — practical checklist and a final template

  1. Remember: start order (started) ≠ ready (healthy).
  2. Define a healthcheck on DBs and queues.
  3. Wire it with condition: service_healthy on dependents.
  4. Still put app-level retry in place (production safety net).

Final compose.yml you can copy-paste:

YAML
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  migrate:
    build: .
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy

  api:
    build: .
    depends_on:
      db:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    restart: unless-stopped

No more getting paged at 3 a.m. by connection refused. The moment you add a condition to depends_on, "why isn't it waiting?" turns into "it just waits."

✦ ✦ ✦
Sources and verification

Sources and checks vary by article. Only articles with a recorded check date, scope and sources below were checked, and only within that scope. For other articles, verify against the current docs for your version before applying anything, and email us about errors. Editorial policy · email us

This article has no recorded human check (date and scope) yet.

Published ·
Topic-related official docs (auto-linked · not per-claim sources)Docker documentation ↗PostgreSQL documentation ↗

Comments

Be the first to comment.