/인프라/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

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 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 부팅 중...... ─┘  ← 아직 포트 안 열림 = 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)10s30s

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 될 때까지 대기
      migrate:
        condition: service_completed_successfully  # 마이그레이션 잡이 0으로 끝날 때까지
      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 연결 실패, 재시도 ${i + 1}/${retries}`);
      await new Promise(r => setTimeout(r, delay * 2 ** i)); // 지수 백오프
    }
  }
  throw new Error("DB 연결 최종 실패");
}

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."

확인 정보
✦ ✦ ✦
편집 검토 · Editorial Review

Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 · ·업데이트 ·

Comments

Be the first to comment.