Skip to content
shellhacks.dev
Docker

How to Use Compose Healthchecks and depends on the Safe Way

Make Compose wait for a ready database: write a healthcheck, use depends_on with condition service_healthy, tune the timings and use up --wait in scripts.

6 min read
Summarize in AI
Illustration for "How to Use Compose Healthchecks and depends on the Safe Way": a terminal session, stacked containers, a process monitor.

An app container that crashes at start with "connection refused" because the database was still initializing is a familiar race. Starting the database container first does not help, since the process inside needs seconds to accept connections. The fix is to tell Compose what "ready" means. It extends the file built in the Compose tutorial. The commands assume Compose 2.30 and the postgres:17 image.

Define ready

compose.yamlYAML
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: dev
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

  web:
    build: .
    depends_on:
      db:
        condition: service_healthy

The health command runs inside the container at each interval. A zero exit means healthy. During start_period failures do not count against the retries, which suits a service that needs a while to initialize. retries: 10 with a 5 second interval gives up after about a minute, after which the container is marked unhealthy and Compose stops waiting.

See the state

Shell
docker compose up -d
docker compose ps
Text
NAME       IMAGE        SERVICE   STATUS                   PORTS
app-db-1   postgres:17  db        Up 14 seconds (healthy)  5432/tcp
app-web-1  app-web      web       Up 3 seconds

The web container was created only after db reached healthy. Details of failed checks are in docker inspect --format '{{json .State.Health}}' app-db-1, which prints the last few check outputs. When a check is wrong, the dependent service never starts and Compose reports "dependency failed to start: container is unhealthy".

Other conditions

service_started is the default and matches the short form. service_completed_successfully waits for a one-shot container to finish with exit code 0, which is the right pattern for a migration job:

YAML
  migrate:
    build: .
    command: ["python", "manage.py", "migrate"]
    depends_on:
      db:
        condition: service_healthy
  web:
    depends_on:
      migrate:
        condition: service_completed_successfully

Wait in scripts and CI

Shell
docker compose up -d --wait --wait-timeout 120
npm test

--wait blocks until the services are running and, for those with a healthcheck, healthy; it exits non-zero on timeout. It replaces hand-written sleep loops in CI. The pattern for services outside Compose is in waiting for a service in an entrypoint.

Where this does not apply

A healthcheck proves the check passes, not that the app works; pg_isready says Postgres accepts connections but not that your schema exists. Choose a check close to what dependents need. Applications should still retry connections, because the database can restart at any time, and depends_on gives no protection after startup. Healthchecks cost a process each interval, so keep them cheap.

For a worked stack that uses these conditions end to end, see running a script at container start and signals and PID 1, which cover what happens when the container is stopped. A healthy status also feeds restart policies: restart: unless-stopped does not restart on an unhealthy status by itself, so pair it with monitoring that reads docker inspect.

Why depends_on alone is not enough

By default depends_on controls only the order in which containers are started. It waits for the dependency container to be running, which happens within a moment of its start, long before the program inside is ready to accept connections. A database takes seconds to initialize its data directory and begin listening, and an application that starts in that gap fails with a connection refused error. The traditional fix was a sleep or a retry loop in the application, and both are fragile. The long form of depends_on, with condition: service_healthy, makes Compose wait until the dependency's healthcheck passes, which moves the knowledge of readiness to the service that knows it, and out of every consumer.

Design the application to retry on connection failure anyway. Databases restart, networks blip, and a dependency that was healthy at startup can become unavailable later. Compose ordering handles the first start only, and an application that exits at the first refused connection will be fragile in production, whatever the compose file does.

Writing a healthcheck that tells the truth

A healthcheck is a command run inside the container at an interval, and its exit status decides the state: zero is healthy, one is unhealthy. Choose a check that exercises what consumers need. pg_isready checks that PostgreSQL accepts connections, mysqladmin ping does the same for MySQL, and for an HTTP service a request to a health endpoint that touches its dependencies is more honest than checking that the port is open. The options interval, timeout, retries and start_period control timing. A start period gives slow services time to initialize without counting failures against them. Set the interval short during startup and accept longer ones later, since a check every second for a service that runs for months is needless load.

The check needs a tool inside the image. A minimal image without curl cannot run a curl-based check, and the container will report unhealthy forever. Use a tool the image includes, add one deliberately, or write the check in the application's own language.

Reading state and handling failures

A container with a healthcheck shows its status in docker compose ps as starting, healthy or unhealthy, and docker inspect shows the last few check outputs under the health log, which is the quickest way to learn why a check fails. Compose waits for the healthy state before starting dependents and gives up if the dependency becomes unhealthy during that wait, with an error that names the service. Other conditions exist: service_started is the old behavior, and service_completed_successfully waits for a one-shot service to exit with zero, which suits migration containers that must finish before the application starts. Combining the three gives a clean startup sequence: database healthy, then migrations complete, then the application.

An unhealthy status does not restart a container by itself in Compose. A restart policy reacts to the process exiting, not to the health state, so an unhealthy container keeps running unless something acts on the status. In Swarm mode, unhealthy tasks are replaced, which is a difference worth knowing when you move between the two.

Waiting in scripts and in CI

Compose can wait for health itself. docker compose up --wait starts the stack and returns only when every service with a healthcheck is healthy, or fails if one becomes unhealthy or the timeout passes, which is exactly what a CI job wants before it runs tests. Pair it with --wait-timeout so a stuck dependency fails the job in minutes and not hours. For commands run with docker compose run, dependencies start first and their health conditions are honored, so a migration command waits for the database without any custom loop. If you need to wait for something outside Compose, such as a service on another host, a small script that polls with a deadline and exits nonzero on timeout is the portable choice.

Avoid a sleep as a substitute. A fixed delay is either too short and fails randomly, or too long and wastes time on every run. A poll with a short interval and an upper limit adapts to the actual startup time and reports clearly when the limit is reached. Print what you are waiting for and how long it has been, so a slow start is visible in the logs.

See the depends_on reference and the healthcheck reference.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.