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

OptionDefaultWhat it controls
--interval30sHow often the probe runs after the container is healthy.
--timeout30sHow long a single probe is allowed to take before it counts as a failure.
--start-period0sGrace window during startup. Failures inside this window do not count towards --retries.
--start-interval5sProbe interval during the start period. Lets startup health be detected quickly.
--retries3Consecutive failed probes required before the container is marked unhealthy.

Exit-code semantics

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 keyDockerfile equivalentNotes
testCMD ...The probe command. Forms below.
interval--intervalDuration string, e.g. 30s, 1m30s.
timeout--timeoutPer-probe time limit.
retries--retriesInteger.
start_period--start-periodUnderscore in Compose, hyphen in the Dockerfile.
start_interval--start-intervalRequires Docker Engine 25.0 or later.
disableHEALTHCHECK NONEtrue 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

WhereHow
DockerfileHEALTHCHECK NONE — removes a healthcheck inherited from the base image.
docker run--no-healthcheck — disables any healthcheck defined in the image for this container.
Docker Composedisable: 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:

Common pitfalls

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.