HEALTHCHECK reference
Last reviewed on 2026-09-25
How the HEALTHCHECK instruction works, how to tune its options, and the pitfalls teams hit when running it in production.
The HEALTHCHECK instruction tells Docker how to test that a container is still working. The runtime calls the configured command at a chosen interval and uses its exit code to set the container's health status. That status is exposed in docker ps, docker inspect, and to higher-level orchestrators that consult it.
Syntax
HEALTHCHECK [OPTIONS] CMD command
HEALTHCHECK NONE
Two forms exist. The first registers a probe; the second explicitly disables a healthcheck inherited from a base image. The CMD here is unrelated to the CMD instruction — it is part of the healthcheck syntax.
Options
| Option | Default | What it controls |
|---|---|---|
--interval | 30s | How often the probe runs after the container is healthy. |
--timeout | 30s | How long a single probe is allowed to take before it counts as a failure. |
--start-period | 0s | Grace window during startup. Failures inside this window do not count towards --retries. |
--start-interval | 5s | Probe interval during the start period. Lets startup health be detected quickly. |
--retries | 3 | Consecutive failed probes required before the container is marked unhealthy. |
Exit-code semantics
- 0 — healthy. The service is responding correctly.
- 1 — unhealthy. The service is not responding correctly. Docker increments the failure count.
- 2 — reserved. Don't use it.
The container starts in the starting state. After the first successful probe (or after enough probes have failed past the retry threshold), it transitions to healthy or unhealthy.
Worked example: HTTP service
FROM nginx:1.27-alpine
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1/ || exit 1
This probe asks the in-container nginx to serve its index. wget --spider performs a HEAD-like request without writing the response to disk; redirecting to exit 1 on failure keeps the script POSIX-clean. --tries=1 is important — without it, wget would retry inside the probe and exhaust the timeout before Docker sees a failure.
Worked example: a service with a dedicated /healthz
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /out/server /server
COPY --from=builder /usr/bin/healthcheck /healthcheck
HEALTHCHECK --interval=10s --timeout=2s --start-period=15s --retries=3 \
CMD ["/healthcheck", "--addr=127.0.0.1:8080", "--path=/healthz"]
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
A distroless image has no shell, so the probe must be a single executable invoked with the exec form (the JSON-array form). Many teams ship a tiny healthcheck binary alongside the application for exactly this reason. The binary should set a short timeout, exit non-zero on any error, and never log to stdout.
Healthchecks without curl: Node.js, Python, PHP, and wget
Many minimal images do not include curl. The Debian-based node:*-slim and python:*-slim images ship neither curl nor wget; Alpine ships BusyBox wget but not curl. Installing curl only for the probe adds size and attack surface. Use the runtime that is already in the image instead.
When curl is available, always pass -f: curl -fsS --max-time 2 http://127.0.0.1:8080/health || exit 1. Without -f, curl exits 0 on an HTTP 500 and the probe passes.
Node.js
FROM node:22-slim
WORKDIR /app
COPY . .
USER node
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD ["node", "-e", "require('http').get('http://127.0.0.1:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
CMD ["node", "server.js"]
The exec form runs node directly, so no shell is needed. The script exits 0 on HTTP 200, 1 on any other status, and 1 on a connection error. On Node.js 18 and later, fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1)) works the same way.
Python
FROM python:3.13-slim
WORKDIR /app
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD ["python", "-c", "import sys, urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"]
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
urlopen raises an exception on connection errors and on 4xx/5xx responses. An uncaught exception makes the interpreter exit with 1, which Docker counts as a failure. The standard library is enough; no requests install is needed.
PHP (Apache and PHP-FPM)
The php:*-apache images serve HTTP on port 80, so PHP itself can make the request:
FROM php:8.3-apache
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD ["php", "-r", "exit(@file_get_contents('http://127.0.0.1/health') === false ? 1 : 0);"]
file_get_contents returns false on a connection error or a 4xx/5xx response. It needs allow_url_fopen, which is on by default.
The php:*-fpm images do not speak HTTP. PHP-FPM listens for FastCGI on port 9000, so an HTTP probe against that port always fails. Enable the pool's ping endpoint and query it with cgi-fcgi:
FROM php:8.3-fpm
RUN apt-get update \
&& apt-get install -y --no-install-recommends libfcgi-bin \
&& rm -rf /var/lib/apt/lists/* \
&& printf '[www]\nping.path = /ping\n' > /usr/local/etc/php-fpm.d/zz-healthcheck.conf
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping REQUEST_METHOD=GET \
cgi-fcgi -bind -connect 127.0.0.1:9000 | grep -q pong || exit 1
The ping endpoint answers pong by default. It only proves FPM is accepting FastCGI requests; it does not run your application code. On php:*-fpm-alpine, the package providing cgi-fcgi is fcgi (apk add --no-cache fcgi). If nginx sits in front of FPM in a separate container, give nginx its own HTTP healthcheck.
wget on Alpine and BusyBox
FROM alpine:3.20
COPY --from=builder /out/server /usr/local/bin/server
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD wget -q -O /dev/null -T 2 http://127.0.0.1:8080/status || exit 1
CMD ["server"]
BusyBox wget exits non-zero on connection errors and on HTTP error statuses, so it works as a probe without extra flags. -T 2 caps the request below the healthcheck --timeout. Use whatever path your service exposes for health (/health, /healthz, /status); the probe only cares about the exit code.
Docker Compose healthcheck: syntax
In Compose, the healthcheck is set per service under the healthcheck: key. It overrides any HEALTHCHECK in the image.
services:
api:
image: myapp:1.4.2
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
start_interval: 5s
| Compose key | Dockerfile equivalent | Notes |
|---|---|---|
test | CMD ... | The probe command. Forms below. |
interval | --interval | Duration string, e.g. 30s, 1m30s. |
timeout | --timeout | Per-probe time limit. |
retries | --retries | Integer. |
start_period | --start-period | Underscore in Compose, hyphen in the Dockerfile. |
start_interval | --start-interval | Requires Docker Engine 25.0 or later. |
disable | HEALTHCHECK NONE | true turns off the image's healthcheck. |
test accepts four forms:
# Exec form: runs the binary directly, no shell
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
# Shell form: runs through the container's default shell (/bin/sh -c on Linux)
test: ["CMD-SHELL", "curl -f http://127.0.0.1:8080/health || exit 1"]
# Plain string: same as CMD-SHELL
test: curl -f http://127.0.0.1:8080/health || exit 1
# Disable the healthcheck
test: ["NONE"]
Use CMD when the image has no shell. Use CMD-SHELL or a string when you need pipes, ||, or environment variable expansion. Compose interpolates $VAR in the file itself, so a variable meant for the container's shell must be written as $$VAR.
Waiting for a healthy dependency: depends_on
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: example
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
api:
image: myapp:1.4.2
depends_on:
db:
condition: service_healthy
With condition: service_healthy, Compose does not start api until db reports healthy. If db becomes unhealthy or has no healthcheck, docker compose up fails with a dependency error. The other conditions are service_started (the default) and service_completed_successfully. The condition is only evaluated at startup; Compose does not stop or restart api if db later turns unhealthy. docker compose up --wait blocks until services are running and healthy, which is useful in CI.
Setting a healthcheck with docker run
A healthcheck can be set or overridden at run time without rebuilding the image:
docker run -d --name api \
--health-cmd='curl -f http://127.0.0.1:8080/health || exit 1' \
--health-interval=15s \
--health-timeout=3s \
--health-retries=3 \
--health-start-period=20s \
--health-start-interval=5s \
myapp:1.4.2
--health-cmd is run through the container's shell, like CMD-SHELL.
Disabling a healthcheck
| Where | How |
|---|---|
| Dockerfile | HEALTHCHECK NONE — removes a healthcheck inherited from the base image. |
docker run | --no-healthcheck — disables any healthcheck defined in the image for this container. |
| Docker Compose | disable: true under healthcheck:, or test: ["NONE"]. |
services:
worker:
image: myapp:1.4.2
healthcheck:
disable: true
Only the last HEALTHCHECK instruction in a Dockerfile takes effect, so a later HEALTHCHECK NONE or HEALTHCHECK CMD ... always replaces an earlier one, including one from the base image.
Reading health status: docker ps and docker inspect
docker ps shows the health state in the STATUS column: Up 5 seconds (health: starting), Up 2 minutes (healthy), or Up 3 minutes (unhealthy). Containers without a healthcheck show no health suffix. Filter by state with --filter health=:
docker ps --filter health=unhealthy # also: starting, healthy, none
For the full record, including the output of recent probes, read .State.Health:
docker inspect --format '{{.State.Health.Status}}' api
docker inspect --format '{{json .State.Health}}' api | jq
{
"Status": "unhealthy",
"FailingStreak": 3,
"Log": [
{
"Start": "2026-09-25T10:14:02.118Z",
"End": "2026-09-25T10:14:05.119Z",
"ExitCode": -1,
"Output": "Health check exceeded timeout (3s)"
}
]
}
Status is starting, healthy, or unhealthy. FailingStreak counts consecutive failures. Log holds the last five probe results; Output is the probe's stdout and stderr, truncated to 4096 bytes, which makes it the first place to look when a container is unexpectedly unhealthy. If the container has no healthcheck, .State.Health is absent: the json form prints null and the .Status form fails with a template error.
Tuning interval, timeout, and start period
The defaults are usable but rarely optimal. A useful set of decision criteria:
- Interval — fast enough to detect a stuck process before the orchestrator restarts it for unrelated reasons.
10s–30sis a typical range. Lower is wasteful; higher means slower detection. - Timeout — strictly less than interval. If a single probe ever blocks, you do not want it overlapping the next one. Set timeout to the worst-case latency of your
/healthzhandler with margin (often2s–5s). - Start period — long enough to cover cold-start work: JVM warm-up, DB pool initialisation, cache priming. Probes inside this window may fail without counting towards
--retries, which avoids tearing down a perfectly healthy container that simply hasn't finished booting. - Retries — keep at
3unless you have a good reason. A higher number masks real failures; a lower number makes transient blips fatal.
Common pitfalls
- Probing through the public network. A healthcheck is checking this container, not the world. Always probe
127.0.0.1or a Unix socket, never a public hostname or load balancer. - Probes that are heavier than the request they protect. A probe that touches a database, runs a query, and warms a cache is a probe that will fail under load — exactly when you don't want it to. Keep
/healthzcheap; put dependency checks behind a separate/readyzif you need them. - Distroless images without a shell. The default
CMDform is parsed by/bin/sh. Distroless images have none. Use the JSON-array exec form, or pick a base image that includes a shell. - No
--start-periodon slow-booting services. Many JVM and Python services need 10–30 seconds to start. Without a start period, the first--retriesfailures during boot mark the container unhealthy and an orchestrator may kill it before it ever finishes starting. - Inheriting a base image's
HEALTHCHECKby accident. If your base image declares one and yours does not, you inherit it. UseHEALTHCHECK NONEto disable, or set your own.
Healthcheck and orchestrators
Most orchestrators have their own probe model. Kubernetes uses liveness, readiness, and startup probes that are configured at the Pod level and override Dockerfile HEALTHCHECK. ECS, Nomad, and Docker Swarm honour HEALTHCHECK directly. As a rule of thumb: include a HEALTHCHECK for portability and for local docker compose use, but assume the orchestrator's own probes are the source of truth in production.
FAQ
What is a basic Docker healthcheck?
A HEALTHCHECK instruction whose command exits 0 when the service works and 1 when it does not, for example HEALTHCHECK CMD curl -f http://127.0.0.1:8080/ || exit 1. Docker runs it every 30 seconds by default and reports the container as starting, healthy, or unhealthy.
What is the Docker Compose healthcheck syntax?
Add a healthcheck: key to the service. test is a ["CMD", ...] list, a ["CMD-SHELL", "..."] list, or a plain string run by the shell. The optional keys are interval, timeout, retries, start_period, and start_interval.
How do I disable a Dockerfile healthcheck?
Add HEALTHCHECK NONE to the Dockerfile to drop one inherited from the base image, start the container with docker run --no-healthcheck, or set disable: true under healthcheck: in Docker Compose.
How do I write a Node.js healthcheck without curl?
Use Node itself: HEALTHCHECK CMD ["node", "-e", "require('http').get('http://127.0.0.1:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]. This works in node:*-slim images, which do not include curl. For Python, use urllib.request the same way.
How do I see a container's health status as JSON?
Run docker inspect --format '{{json .State.Health}}' <container>. The output contains Status, FailingStreak, and a Log of the last five probe results with their exit codes and output.
Does Docker restart an unhealthy container?
No. Restart policies only react to the container exiting, and plain docker run and Docker Compose only report the health status. Docker Swarm does replace unhealthy tasks.
Related
- CMD reference — the difference between shell form and exec form, which applies here too.
- ENTRYPOINT reference — how the main process is launched.
- USER reference — running as non-root; probes run as the container's user.
- FROM reference — picking a base image that includes a shell when your healthcheck needs one.
- Security best practices for Docker images — minimal base images and probe shape.