Docker & ContainersIX · Docker ComposeHealthchecks
Healthchecks in Compose — and what they do not do
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
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 psSTATUS column, as(healthy)or(unhealthy). - The
/containers/<id>/jsonAPI response, underState.Health. depends_on: condition: service_healthyduring 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
| Field | Default | What it controls |
|---|---|---|
test | — | The command. NONE, CMD (exec form) or CMD-SHELL (through /bin/sh -c) |
interval | 30s | Gap between checks, measured from the end of the previous one |
timeout | 30s | How long one run may take before it is SIGKILLed and counted as a failure |
retries | 3 | Consecutive failures needed to flip healthy to unhealthy |
start_period | 0s | Grace window at startup during which failures are not counted |
start_interval | 5s | Gap between checks during the start period. Requires Engine 25.0+ |
disable | false | Equivalent 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.
$ 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
- 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.
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
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 dbIf 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
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?
Q2. What exit status must a healthcheck command return to report healthy, and which value is reserved?
Q3. A healthcheck whose command ends in `|| exit 0` reports healthy no matter what the service is doing.
Q4. Which statements about `start_period` are correct? Select all that apply.
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.