Docker HEALTHCHECK and restart_policy: when containers heal themselves – and when they don't
HEALTHCHECK options, restart_policy modes and depends_on: condition: service_healthy explained – with a timing formula, a verbatim error, and the gotcha most people miss.
A container with status running is not proof that the application is responding. Docker monitors whether the main process is still alive — not whether it delivers meaningful responses. A database can be up but still not accepting connections. An API can be started but waiting for a missing secret. With HEALTHCHECK you give Docker a concrete test: run this command, check the exit code, and update the container's health state accordingly. restart_policy decides what happens after a crash — but it has a limitation that surprises many people.
What Docker cannot see without a healthcheck
running ≠ functional
docker ps shows Up 3 minutes and Status: running. That tells you exactly one thing about the application: the process is still alive. A Node.js server whose database connection has dropped and which responds to every request with 500 Internal Server Error is healthy from Docker's perspective — because no healthcheck has determined otherwise. A PostgreSQL container still in initialization mode and not accepting connections is equally healthy. That is the blind spot, and in stacks with service dependencies it leads to hard-to-trace startup failures.
The blind spot has real costs in the worst case: anyone who wants to know what an hour of unplanned downtime means for their business can estimate it quickly with the downtime cost calculator.
Which services benefit most from healthchecks
The impact is greatest where other services wait on a dependency: databases, message queues, caches. Using depends_on in a Compose stack without a healthcheck gives you only a start sequence — not a guarantee that the dependency is operational. Only depends_on: condition: service_healthy provides that guarantee, and for that the dependency needs a configured healthcheck. Services with slow initialization — JVM application servers, Django migrations, Elasticsearch cluster bootstrap — also benefit from a carefully tuned healthcheck.
Writing HEALTHCHECK: Dockerfile and Compose syntax compared
Options and their defaults
All five options apply equally to Dockerfile and Compose YAML. Docker Engine 29.8.1 (current as of September 2026) uses the following defaults, defined in the Moby source code under daemon/health.go:
| Option | Default | Meaning |
|---|---|---|
interval |
30s | Time between probes |
timeout |
30s | Maximum duration of the probe command |
retries |
3 | Consecutive failures before unhealthy |
start_period |
0s | Grace period: failures do not count during this time |
start_interval |
5s | Probe interval during start_period (Docker ≥ 25.0) |
Docker has three health states for a container: starting (still in the grace phase), healthy (last probe succeeded) and unhealthy (too many consecutive failures). No healthcheck configured? The container has no health state at all — docker inspect shows no Health key.
Dockerfile syntax:
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
--start-period=30s --start-interval=5s \
CMD pg_isready -U app -d appdb
Note: the Dockerfile uses hyphens (--start-period), while Compose YAML uses underscores (start_period).
Compose syntax:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
start_interval: 5s
CMD vs. CMD-SHELL: when do you need a shell?
["CMD", "pg_isready", "-U", "app"] executes the command directly — no shell overhead, no variable expansion. This is the right form when the tool can be called directly. ["CMD-SHELL", "pg_isready -U app || exit 1"] runs the expression via /bin/sh -c and enables shell features like ||, pipes, and parameter substitution.
Distroless and scratch images have no shell. CMD-SHELL fails in these images with exec: "/bin/sh": stat /bin/sh: no such file or directory. Anyone building an image following the multi-stage pattern and deliberately omitting tools from the runtime image should keep this in mind: either copy a static healthcheck binary into the runtime stage, or use a database-specific command that is already present in the image.
A common mistake is using curl --fail http://localhost/health as a healthcheck in an image that does not contain curl. Docker then records the following error in .State.Health.Log:
OCI runtime exec failed: exec failed: unable to start container process:
exec: "curl": executable file not found in $PATH
After --retries failures the container moves to unhealthy. Better alternatives: wget -qO- http://localhost/health (more often present in Alpine images than curl), pg_isready for PostgreSQL, redis-cli PING for Redis.
start_period and start_interval: fast polling during startup
start_period defines a grace period during which failures do not count against --retries — designed for services with slow initialization: database migrations, JVM warm-up, Elasticsearch cluster bootstrap. start_interval allows faster probing during this grace period than in normal operation (introduced with Docker Engine 25.0).
Important caveat: start_interval is silently ignored when start_period is 0s — the default. Setting start_interval: 5s without specifying a start_period produces no faster polling whatsoever. This behavior is documented (moby/moby issue #49900) but not highlighted prominently in the official prose documentation.
restart_policy: four modes with one crucial difference
Comparison table
| Value | Restarts after crash? | Restarts after docker compose down + daemon restart? |
|---|---|---|
no (default) |
No | No |
on-failure |
Yes, on non-zero exit code | No |
unless-stopped |
Yes | Only if no manual stop preceded it |
always |
Yes | Always — even after a manual stop |
Why unless-stopped is the better choice for self-hosted services
always and unless-stopped behave identically in normal operation. The difference: with restart: always, Docker restarts the container after a system reboot even if you stopped it manually with docker compose down. With restart: unless-stopped, Docker remembers the deliberate stop — the container stays down after a reboot. This is the right behavior for self-hosted services: come back up after a reboot, but docker compose down actually means the stack stays down. In the Authelia SSO configuration — where an unintended restart of a forward-auth component can lock access to all services behind it — this is the decisive difference.
The most important misconception in this area: restart_policy in standalone Docker Compose responds to the main process's exit code — not to the HEALTHCHECK status. A container that is running but unhealthy is not restarted by Compose. Only Docker Swarm (deploy.restart_policy) uses health status as a restart trigger. For standalone setups you need either a wrapper script that actively terminates the process when health checks fail persistently, or an external monitoring tool.
A complete minimal example: Postgres and a web app
compose.yaml step by step
The following example shows a Postgres container with a carefully tuned healthcheck and a web app that only starts once the database is actually ready:
services:
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
start_interval: 5s
volumes:
- db_data:/var/lib/postgresql/data
web:
image: ghcr.io/example/myapp:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://app:secret@db:5432/appdb
volumes:
db_data:
pg_isready is included in the official Postgres image and is the most reliable way to test whether Postgres accepts connections. It returns exit code 0 when the server is reachable and exit code 1 on problems. No curl, no HTTP request, no additional package needed.
depends_on: condition: service_healthy
When docker compose up runs, web enters the Created state and waits. Compose periodically checks the health status of db. Only once db reports healthy does web start. Without condition: service_healthy, web would start immediately after db — even if Postgres is still initializing.
+--------+ depends_on +----------+
| web | -----------> | db |
|(Created) |(starting)|
+--------+ +----------+
| |
| [pg_isready OK]
| |
| +---------+
+------ starts <---| db |
|(healthy)|
+---------+
In addition to service_healthy, depends_on supports two more conditions: service_started (equivalent to the short form depends_on: [db]) and service_completed_successfully for initialization or migration containers that are expected to exit with code 0.
Measuring timing and states
How long until unhealthy? The formula
With the defaults (interval=30s, retries=3, start_period=0s), it takes a maximum of 90 seconds for a container to become unhealthy: three consecutive failures at 30-second intervals. With the configuration in the example above (start_period=30s, start_interval=5s, interval=10s, retries=5) the calculation is:
- During
start_period(30 s): probes every 5 s, failures are not counted - After
start_period: probes every 10 s, 5 consecutive failures needed - Worst case until
unhealthy: 30 s + 5 × 10 s = 80 seconds
This formula matters for depends_on: condition: service_healthy: Compose waits until the service becomes healthy, or aborts when its internal wait timeout is exceeded. For services with long startup times, start_period should be set generously — a value that is too low causes false unhealthy reports during the very first start.
docker inspect and docker events
Checking the current health status of a running container:
docker inspect --format='{{json .State.Health}}' <container_name> | jq .
The output contains Status, FailingStreak and the last five probe results (Log) with exit code, start and end time, and the full output of the probe command. This is the first place to look when a service unexpectedly becomes unhealthy. Docker truncates each log entry at 4096 bytes — probe commands that write a lot to stdout should be quieted with > /dev/null or 2>&1.
Watching state transitions in real time:
docker events --filter event=health_status
This stream shows every transition between starting, healthy and unhealthy with a timestamp — useful for debugging timing problems in depends_on chains. An overview of all running containers including health status short-form:
docker ps --format "table {{.Names}}\t{{.Status}}"
Frequently asked questions
Does an unhealthy status trigger an automatic restart?
No — this is the most important misconception on the topic. restart_policy in standalone Docker Compose responds to the main process's exit code, not to the healthcheck status. A container that is running but unhealthy is not restarted. Only Docker Swarm with deploy.restart_policy: condition: any considers health status as a restart trigger. For standalone setups the wrapper script or the application itself must actively terminate the process when the service is no longer functional.
Can I disable the healthcheck from a base image?
Yes. In the Dockerfile with HEALTHCHECK NONE, in Compose YAML with disable: true under healthcheck:. This is particularly useful for lean production images that inherit a debug base image with a built-in healthcheck, or when healthchecking is handled by a different mechanism such as a Kubernetes liveness probe or AWS ECS health check.
What to do when the healthcheck command is not available in the image?
Most often curl is missing from slim Alpine or distroless images. Alternatives: wget (more often present in Alpine images), pg_isready for PostgreSQL, redis-cli PING for Redis. Anyone building an image following the multi-stage pattern can download a static binary in the build stage and copy it into the runtime stage without enlarging the runtime image.
Can depends_on wait for multiple services with service_healthy?
Yes. The long form accepts any number of dependencies:
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
migrations:
condition: service_completed_successfully
service_completed_successfully is designed for migration containers that should exit with code 0 after completion. The web app only starts once both the database and the cache report healthy and the migrations have run through.
Why does the healthcheck fail intermittently even though the service is running?
Most common cause: the timeout value is too low. The probe command runs longer than expected under load — especially for HTTP requests against a busy application or during the first cold start of a JVM service. A realistic timeout value is between 5 s and 10 s. The actual runtime of each probe is visible in the Log array from docker inspect and helps identify timeouts that are set too aggressively.
Further reading
The fully wired Compose stack — custom network, volumes, service dependencies — is covered in the Docker Compose multi-service post. For keeping images lean and making tools like pg_isready available in runtime images, the multi-stage Dockerfile example is the logical next step. For backups of the running stack, the Restic post covers encrypted, automated backups to S3.
Official reference: HEALTHCHECK in Dockerfile · healthcheck in Compose · restart in Compose.
For questions about stable container stacks, IT support is the right starting point.
Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the Impressum).