Article start

Docker Compose Health Checks and Startup Order: Fix depends_on Race Conditions

Containers can be running before an application is ready. Build a reliable Compose startup sequence with PostgreSQL health checks, service_healthy, bounded application retries, and practical diagnostics.

A Docker container reaching the running state tells you that its main process started. It does not tell you that PostgreSQL has finished recovery, an API has warmed its caches, or a migration has completed. That small distinction causes a familiar Compose failure: the API starts first, attempts one database connection, exits, and appears flaky even though every image is healthy.

Docker Compose startup flow from a PostgreSQL health check to a ready API service
A dependable startup path separates process creation from application readiness.

The fix has two layers. Compose can gate initial creation of a dependent service on a dependency health check. The application must still tolerate failures after startup with bounded retries and reconnection. Treating those layers as complementary produces a stack that starts predictably without pretending that a startup rule is a runtime service manager.

Running, healthy, and ready are different states

Short-form depends_on expresses ordering: Compose creates the dependency before the dependent service. By itself, it waits for the dependency container to run, not for the software inside it to accept useful work. Long-form depends_on adds conditions that describe what the dependent service needs.

ConditionWhen it is usefulWhat it does not prove
service_startedThe dependency process only needs to start.The application is ready for requests.
service_healthyA declared health check must pass first.The dependency will remain healthy forever.
service_completed_successfullyA one-shot task, such as setup work, must exit successfully.Later services cannot fail at runtime.

For a database-backed API, service_healthy is usually the correct startup condition. For a one-time schema job, a separate migration service with service_completed_successfully can be clearer. Avoid hiding long, state-changing migrations inside a shallow port check.

A production-minded Compose example

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

  api:
    build: ./api
    environment:
      DB_HOST: db
      DB_PORT: "5432"
      DB_NAME: app
      DB_USER: app
      DB_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
    depends_on:
      db:
        condition: service_healthy
    restart: on-failure

volumes:
  pgdata:

The doubled dollar signs in the health-check command are intentional. They defer expansion so the command inside the container receives POSTGRES_USER and POSTGRES_DB. A single dollar expression can be interpolated by Compose on the host instead.

pg_isready answers a narrow and valuable question: is PostgreSQL accepting connections? It does not verify that every application table exists or that business data has loaded. If the API requires a stronger invariant, keep database migrations as an explicit deployment step rather than turning the health check into a slow query with side effects.

Choose health-check timing deliberately

  • start_period gives initialization time before failures count toward the retry threshold.
  • interval controls how often checks run.
  • timeout bounds an individual check.
  • retries is the number of consecutive counted failures before the container becomes unhealthy.

The overall wait is not a single exact multiplication because check duration and scheduling matter. Use a budget based on measured cold starts, recovery after an unclean shutdown, and slower developer machines. A two-second happy-path startup does not justify a five-second total failure budget if recovery sometimes takes forty seconds.

Startup ordering cannot replace resilience

Once the API is running, the database can restart, a network connection can expire, or the container can be recreated with a new IP address. The API should retry transient connection failures with exponential backoff, cap each attempt, and stop retrying errors that are clearly permanent. Connection pools should discard broken sockets and resolve the stable service name again.

This is also why restart: on-failure is a safety net rather than the main readiness design. A crash loop can add load to an already struggling dependency. Prefer an application that can stay alive, report not-ready when appropriate, and reconnect without losing in-flight work.

Apply the same discipline to one-shot initialization. If an API requires a migration, model the migration as an observable operation with an owner, timeout, and idempotency strategy. Starting several API replicas that all race to modify the schema can create locks or partial deployment. Conversely, declaring the database healthy only after one application-specific migration couples a shared database check to one client. Keep infrastructure health, release sequencing, and application retry separate enough that each failure has a clear diagnosis.

Debug the state you actually have

docker compose config
docker compose ps
docker compose logs --tail=100 db
docker compose exec db pg_isready -U app -d app

docker compose config catches interpolation and merge surprises. The service list shows health status, while database logs explain recovery, authentication, or storage failures. Run the same readiness command inside the dependency container to separate a bad check from a genuinely unavailable service.

Common pitfalls

  • Using only short-form depends_on and assuming it waits for readiness.
  • Installing curl at container startup merely to run a check; the probe tool should already exist in the image.
  • Embedding a real password in compose.yaml or committing an environment file.
  • Making a health check perform migrations, writes, or expensive joins.
  • Assuming a passed startup check eliminates the need for runtime retry logic.

Next, read Docker container networking to understand why the API connects to db:5432, then compare this local health model with Kubernetes startup, readiness, and liveness probes.

Frequently asked questions

Does depends_on wait until PostgreSQL is ready?

Only when long-form depends_on uses condition: service_healthy and PostgreSQL has a valid health check. Short-form ordering only waits for container startup.

Will Compose restart my API whenever the database becomes unhealthy?

No. The startup condition is not continuous dependency orchestration. Implement reconnect behavior in the API and use observability to detect recurring dependency failures.

Should an HTTP health check call every downstream service?

Usually not. Keep checks fast and scoped. A readiness decision may include a truly required dependency, but a broad dependency fan-out can amplify an outage.

Where is the authoritative behavior documented?

See Docker's official guide to controlling startup and shutdown order in Compose and the Compose healthcheck reference.

Test your production-readiness knowledge

Use the linked assessment to check startup ordering, networking, storage, Kubernetes health, resources, and rollouts.

Quick quiz
DevOps

Docker and Kubernetes Production Readiness Mock Test

A practical assessment covering Docker Compose health checks, container DNS and storage, Kubernetes probes, resources, OOM diagnostics, and safer rolling updates.

15 questions25 min
Inline play

Start the trivia-style player right inside the article.

View details

Practical takeaway: gate initial startup with the narrowest useful health check, then design the application as though every dependency can disappear again one second later.

Discussion

0 comments

Sign in to share a question or add to the discussion.
Start the discussion

Ask a question or share what stood out to you.