Docker & ContainersXX · Health & Failure DetectionHealthchecks
Health checks — what they can and cannot do
What you'll learn
- Describe the healthcheck state machine, including what `--start-period` actually does
- Compute worst-case detection time from `interval`, `timeout` and `retries`
- Recognise a false green — a check that passes while the service is unusable
- Explain why an unhealthy container keeps running and keeps taking traffic
- Read `State.Health.Log` to find out why a check failed
Prerequisites
None — start here.
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 Docker healthcheck is a command the daemon runs inside your
container on a timer. If it exits 0 the container is healthy; if it
exits non-zero enough times in a row, the container is unhealthy.
That is the entire mechanism. Everything people expect to follow from
it — a restart, a load balancer update, a page — is something else’s
job, and on a standalone Docker host most of those jobs are unfilled.
This lesson is about the gap between what the green word in
docker ps means and what people read it to mean.
The state machine
A container with a healthcheck is always in exactly one of four states, and the transitions have rules that are worth knowing precisely because two of them are counter-intuitive.
| Status | Meaning |
|---|---|
none | no healthcheck is defined |
starting | the container has started and no check has yet succeeded |
healthy | the last check exited 0 |
unhealthy | retries consecutive checks have failed, after the start period |
The probe’s exit code is interpreted strictly:
| Exit code | Result |
|---|---|
0 | healthy |
1 | unhealthy |
2 | reserved — treated as unhealthy |
| anything else | error running the probe — treated as a failure |
That last row is the one that bites. A probe that cannot be executed
at all — the binary is not in the image — exits 127, and the
container sits unhealthy forever with an application that is
working perfectly.
The five options and their real defaults
HEALTHCHECK --interval=10s \
--timeout=3s \
--start-period=60s \
--start-interval=2s \
--retries=3 \
CMD curl -fsS --max-time 2 http://localhost:8080/healthz || exit 1| Option | Default | What it bounds |
|---|---|---|
--interval | 30s | time between checks once the container is past the start period |
--timeout | 30s | how long one check may run before it is killed and counted as a failure |
--retries | 3 | consecutive failures needed to go unhealthy |
--start-period | 0s | grace window after start during which failures do not count |
--start-interval | 5s | time between checks during the start period |
The --timeout default of 30s matching the --interval default of
30s is worth pausing on: with both at their defaults, a hanging
check occupies the entire interval, so checks run back to back with no
gap. Set --timeout to a fraction of --interval, not a value near
it.
Detection-time arithmetic
Two numbers matter and people usually only think about one.
Time to detect a failure ≈
retries× (interval+ worst-case check duration), where the worst case istimeout.With the defaults —
interval 30s,timeout 30s,retries 3— that is up to 3 minutes of a broken container reporting healthy.
| Configuration | Worst-case detection |
|---|---|
defaults (30s / 30s / 3) | ~180s |
--interval=10s --timeout=3s --retries=3 | ~39s |
--interval=5s --timeout=2s --retries=2 | ~14s |
--interval=2s --timeout=1s --retries=2 | ~6s |
Tightening this is not free. The probe runs inside the container,
against the same CPU and memory limits as the application, and a check
that costs 40 ms every 2 seconds is 2% of a core doing nothing but
answering itself. More importantly, a short retries makes the check
sensitive to a single slow response — one GC pause longer than
timeout and a healthy container is marked unhealthy.
The shape that works: retries of at least 2 so a single blip cannot
flip the state, timeout well under interval, and an interval
sized against how quickly anything downstream can actually react.
Detecting a failure in 6 seconds is pointless if the only consumer is
a human reading a dashboard every morning.
The false green
A healthcheck’s value is entirely determined by how much of the request path it exercises. Most of them exercise none of it.
| Check | Proves | Misses |
|---|---|---|
CMD exit 0 | nothing | everything |
nc -z localhost 8080 | a socket is listening | the process that accepted it may be deadlocked |
curl -f http://localhost:8080/ | the HTTP server answers | routing, the database, the queue, the disk |
curl -f http://localhost:8080/healthz returning a constant 200 | the framework is routing | every dependency |
/healthz that opens a DB connection and runs SELECT 1 | the app can reach its database | write path, disk full, downstream APIs |
The rule: a healthcheck must exercise the dependency path it claims to cover, or it is asserting rather than checking.
An endpoint that returns 200 OK from a handler with no body is not
measuring your application; it is measuring your web framework. When
the database connection pool is exhausted and every real request is
failing, that endpoint keeps returning 200, docker ps keeps saying
healthy, and the dashboard is green over a total outage.
CONTAINER=web
# What is the daemon actually configured to run?
docker inspect -f '{{json .Config.Healthcheck}}' "$CONTAINER"
# Run the same command in the same container and read the exit code.
docker exec "$CONTAINER" curl -fsS --max-time 2 http://localhost:8080/healthz
echo "exit=$?"
# Prove it fails when it should: point it at a route that does not exist.
docker exec "$CONTAINER" curl -fsS --max-time 2 http://localhost:8080/definitely-not-a-route
echo "exit=$?"The second docker exec is the part people skip and the part that
matters. A check that has never been observed failing is a check you
have not tested — you have confirmed it can return zero, which was
never in doubt.
The misconception: Docker does not restart an unhealthy container
This is the single most important sentence in the lesson.
A container marked
unhealthykeeps running. The daemon writes the status, emits an event, and does nothing else. It does not stop it, does not restart it, and does not stop it receiving traffic on a published port.
Restart policies and health status are separate mechanisms that do
not interact. restart: always restarts a container that exits.
An unhealthy container has not exited — that is the definition of
being a running container with a failing probe — so the restart policy
never fires. Setting restart: unless-stopped in the hope of getting
“restart when unhealthy” produces exactly nothing.
What does consume health status:
| Consumer | Effect | Available in standalone Docker |
|---|---|---|
Compose depends_on: condition: service_healthy | delays dependent startup only | yes |
| Docker Swarm | replaces the task | only in Swarm mode |
Traefik loadbalancer.healthcheck | stops routing — its own check, not Docker’s | yes, but it is Traefik’s probe |
docker events / docker ps --filter health=unhealthy | tells you | yes, if something is watching |
| Kubernetes liveness probe | restarts the container | not Docker |
Note the Traefik row carefully: a proxy that “respects health” is
running its own probe against the application, not reading Docker’s
State.Health.Status. Two independent health checks with different
intervals and different endpoints is the normal state of affairs, and
they can disagree.
#!/usr/bin/env bash
set -euo pipefail
# Only ever restart containers that opt in with a label, so this can
# never act on a database that happens to fail one probe.
docker events --filter 'event=health_status' --format '{{.Actor.Attributes.name}} {{.Status}}' | while read -r name status; do
[ "$status" = 'health_status: unhealthy' ] || continue
optin=$(docker inspect -f '{{index .Config.Labels "autoheal"}}' "$name" 2>/dev/null || true)
[ "$optin" = 'true' ] || continue
logger -t autoheal "restarting unhealthy container $name"
docker restart "$name"
doneReading the health log
The status word is the least useful part of the output. The log is where the answer is.
$ docker inspect --format '{{json .State.Health}}' web | python3 -m json.tool{
"Status": "unhealthy",
"FailingStreak": 7,
"Log": [
{
"Start": "2026-08-12T02:14:31.118Z",
"End": "2026-08-12T02:14:33.121Z",
"ExitCode": -1,
"Output": "Health check exceeded timeout (2s)"
},
{
"Start": "2026-08-12T02:14:41.130Z",
"End": "2026-08-12T02:14:41.288Z",
"ExitCode": 22,
"Output": "curl: (22) The requested URL returned error: 503"
}
]
}Illustrative output
Read the exit codes as a diagnosis:
| Exit code in the log | Means |
|---|---|
-1 with “exceeded timeout” | the probe was killed at --timeout; the application is hanging, not erroring |
7 (curl) | connection refused — nothing is listening on that port inside the container |
22 (curl) | the server answered with 4xx or 5xx — the application is up and rejecting |
28 (curl) | curl’s own --max-time fired before Docker’s timeout |
127 | the probe binary is not in the image |
FailingStreak is the counter that --retries is compared against.
Watching it climb and reset is how you distinguish a genuinely broken
service from one that fails one probe in ten — the second is a
--retries or --timeout problem, not an application problem.
# Unhealthy right now
docker ps --filter health=unhealthy --format 'table {{.Names}}\t{{.Status}}'
# Containers with NO healthcheck - usually a bigger finding than an
# unhealthy one, because nothing is watching them at all.
docker ps --filter health=none --format 'table {{.Names}}\t{{.Image}}'
# Watch transitions live. Flapping shows as repeated pairs.
docker events --filter 'event=health_status' --format '{{.Time}} {{.Actor.Attributes.name}} {{.Status}}'The health=none query is the one to run on a host you have just
inherited. An unhealthy container is at least being measured; a
container with no healthcheck is invisible to every mechanism in this
part of the course, including depends_on: service_healthy, which
silently degrades to “started” when the dependency has no check.
Two levels, and which one Docker gets
Most well-built applications expose two endpoints:
/health/live— is the process responsive? Cheap, no dependencies. Answers “should this be restarted?”/health/ready— can it serve a real request right now? Checks the dependencies it needs. Answers “should this get traffic?”
Kubernetes consumes both, as liveness and readiness probes, and does different things with them. Docker has exactly one healthcheck and no built-in consumer, which forces a choice.
Point Docker’s healthcheck at readiness. The reasoning: the only
things that consume it on a standalone host are depends_on: service_healthy and whatever you write, and both of those are asking
“is it usable yet”, which is readiness. Liveness has no consumer
because Docker will not restart on unhealthy anyway.
Verification that can fail
set -euo pipefail
APP=web
DEP=postgres
# Baseline: it should be healthy.
docker inspect -f '{{.State.Health.Status}}' "$APP"
# Break the dependency the check claims to cover.
docker stop "$DEP"
# Wait out (retries x interval) plus a margin, then look again.
sleep 45
docker inspect -f '{{.State.Health.Status}}' "$APP"
# Put it back and confirm recovery. One success is enough to go healthy.
docker start "$DEP"
sleep 20
docker inspect -f '{{.State.Health.Status}}' "$APP"If the middle reading is still healthy, your healthcheck does not
check what you think it checks — and you have found that out on a
Tuesday afternoon rather than during an incident. That sequence is the
only meaningful test of a healthcheck, and it takes ninety seconds.
Knowledge check
Knowledge check · 4 questions
Q1. A container has `restart: unless-stopped` and its healthcheck has been failing for ten minutes. What has Docker done about it?
Q2. What does `--start-period=60s` actually change?
Q3. Which of these are false greens — checks that can pass while the service is unusable? Select all that apply.
Q4. With the default `--interval`, `--timeout` and `--retries`, a broken container can report healthy for around three minutes.
Passing score: 75%. Answers are checked in this browser.
Where next
Restart policies are the mechanism people reach for when they discover Docker will not restart an unhealthy container. The next lesson covers what they actually do, including the backoff, and why they are not that mechanism.