Skip to main content
RunBook Academy

Docker & ContainersIX · Docker ComposeHealthchecks

Healthchecks in Compose — and what they do not do

Intermediate⏱ ~26 mindocker

What you'll learn

  • Configure healthchecks whose timings match the service they describe
  • Read `State.Health.Log` to find out why a check is failing
  • Distinguish a healthcheck that is wrong from a service that is broken
  • Avoid the three healthchecks that report healthy when the service is not

Prerequisites

Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12

Not yet marked complete on this device.

A healthcheck is a command the daemon runs inside the container to decide whether the container is healthy. The result feeds four things:

  • The docker ps STATUS column, as (healthy) or (unhealthy).
  • The /containers/<id>/json API response, under State.Health.
  • depends_on: condition: service_healthy during startup.
  • docker compose up --wait, which blocks until every service is running or healthy.

Nothing else. In particular a healthcheck does not restart the container, does not remove it from anything, and does not page anyone. It is a signal, and a signal is only as good as what produces it.

Anatomy

services:
  api:
    image: myorg/api:1.4.0
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 60s
      start_interval: 2s
FieldDefaultWhat it controls
testThe command. NONE, CMD (exec form) or CMD-SHELL (through /bin/sh -c)
interval30sGap between checks, measured from the end of the previous one
timeout30sHow long one run may take before it is SIGKILLed and counted as a failure
retries3Consecutive failures needed to flip healthy to unhealthy
start_period0sGrace window at startup during which failures are not counted
start_interval5sGap between checks during the start period. Requires Engine 25.0+
disablefalseEquivalent to test: ["NONE"]; switches off a check inherited from the image

The exit status is the whole protocol:

  • 0 — healthy.
  • 1 — unhealthy.
  • 2 — reserved. Do not use it; a check that exits 2 is not “extra unhealthy”, it is undefined.

Anything the check writes to stdout or stderr is captured — the first 4096 bytes — and stored where you can read it back. That is the single most useful debugging property of the whole mechanism and almost nobody uses it.

Reading why a check is failing

docker ps tells you a container is unhealthy. It does not tell you why. State.Health.Log does, and it holds the last five runs with their exit codes and captured output.

Read-only / Safehealth log
$ docker inspect --format '{{json .State.Health}}' shop-api-1 | jq '.Status, .FailingStreak, (.Log[-1] | {Start, ExitCode, Output})'
"unhealthy"
4
{
"Start": "2026-08-12T04:11:07.318Z",
"ExitCode": 7,
"Output": "curl: (7) Failed to connect to 127.0.0.1 port 8080 after 0 ms: Couldn't connect to server\n"
}

Illustrative output

Exit code 7 is curl’s “could not connect”, not the healthcheck protocol’s

  1. The daemon treats any non-zero as unhealthy, and the specific value is the clue: 7 means nothing is listening, 22 means the server answered with an HTTP error, 28 means the request timed out. Three completely different faults, distinguished for free by a field most people never read.
Read-only / Safetriage
SERVICE=api

# 1. What does the daemon think?
docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Health}}'

# 2. Run the exact check by hand and read the exit status
docker compose exec "$SERVICE" curl -fsS http://127.0.0.1:8080/healthz
echo "exit: $?"

# 3. If step 2 fails with 'executable file not found', the check is the bug
docker compose exec "$SERVICE" sh -c 'command -v curl || echo NO CURL IN IMAGE'

Step 3 is the one that saves the most time. It distinguishes “the service is down” from “the healthcheck was never able to run in the first place”.

The three healthchecks that lie

The one that always passes

test: ["CMD-SHELL", "curl -f http://localhost/ || exit 0"]

The || exit 0 was added during an incident to stop the container being marked unhealthy, and never removed. It reports healthy unconditionally. A healthcheck that cannot fail is strictly worse than no healthcheck, because it converts “we do not know” into “we are fine”.

The one that checks the wrong layer

curl -f http://localhost/ against a reverse proxy or an application server returns 200 from a default page, an error template, or a maintenance placeholder. wget --spider / has the same problem. So does a TCP-connect check: the socket being open says the process bound a port, which happened before it loaded its config.

A /healthz endpoint is only useful if it fails when the service cannot do its job. That usually means it touches the database, or at least reports the result of the last attempt to.

The one that is too shallow, or too deep

pg_isready -U app returns 0 as soon as the postmaster accepts connections. On a Postgres replaying WAL after an unclean shutdown, that happens before it will run a query — it answers FATAL: the database system is starting up. Adding -d app forces a real database name into the check and is strictly better.

Deep checks fail the other way. A /healthz that queries three downstream services on every run, at interval: 5s, across 20 containers, is 12 requests per second of pure overhead — and when the downstream is struggling, the healthchecks pile on and turn a slow dependency into a cascading outage. Check what this service needs to serve a request, not the whole system.

Verification that can fail

Read-only / Safeprove it
SERVICE=api

# It reports healthy now
docker compose ps --format '{{.Service}} {{.Health}}' | grep "^$SERVICE healthy$"

# It notices a real fault: stop the dependency the check exercises,
# then confirm the status flips within interval * retries
docker compose stop db
sleep 40
docker inspect --format '{{.State.Health.Status}}' "$(docker compose ps -q "$SERVICE")"
docker compose start db

If the status stays healthy after its database has been stopped for longer than interval * retries, the check does not exercise the database and you have learned something the happy path would never have told you.

Knowledge check

Knowledge check · 5 questions

  1. Q1. A container reports unhealthy. `State.Health.Log` shows ExitCode 127 and Output containing "executable file not found in $PATH", with each run lasting about 3 ms. What is wrong?

  2. Q2. What exit status must a healthcheck command return to report healthy, and which value is reserved?

  3. Q3. A healthcheck whose command ends in `|| exit 0` reports healthy no matter what the service is doing.

  4. Q4. Which statements about `start_period` are correct? Select all that apply.

  5. Q5. With `interval: 30s` and `retries: 3`, how long can a service serve errors before Docker marks it unhealthy?

Passing score: 75%. Answers are checked in this browser.