Skip to content
Blog

Writing Useful HEALTHCHECK Instructions in a Dockerfile

A HEALTHCHECK that always passes is worse than none. A practitioner guide to the instruction forms, timing options and defaults, how health status reaches docker ps and Compose depends_on, and the checks that lie while your app is broken.

Published on • October 17, 2026

AI Assistant

A running process is not a working service. Your Node API can be wedged in a heap-restore loop, your Postgres can be accepting connections it will never answer, and from the outside - docker ps, your orchestrator, your deploy script - everything looks green because PID 1 has not exited.

The HEALTHCHECK instruction is how a Dockerfile closes that gap. It tells the Docker daemon to periodically run a command inside the container and interpret the exit code as a verdict: this container is, or is not, currently able to do its job. That verdict then flows into docker ps, docker inspect, a health_status event stream, and Compose’s depends_on: condition: service_healthy.

This post is the Dockerfile-level deep dive: exact syntax, exact timing defaults, how to pick a check that is cheap and meaningful, and the failure modes that make a healthcheck worse than useless. If you are specifically wiring healthchecks into Compose startup ordering, our earlier post on Compose startup ordering with healthchecks and depends_on covers that angle; here we stay with the instruction itself and what it does at the engine level.

What HEALTHCHECK actually does

Docker’s own framing is the right mental model: a healthcheck “can detect cases such as a web server stuck in an infinite loop and unable to handle new connections, even though the server process is still running.” The instruction has exactly two forms:

HEALTHCHECK [OPTIONS] CMD command
HEALTHCHECK NONE

The first declares a check. The second disables any healthcheck inherited from the base image - useful when you build on an image that ships one you do not want.

When a container has a healthcheck, it gains a health status alongside its normal running status:

  • It starts at starting.
  • Whenever a check passes, it becomes healthy, regardless of its previous state.
  • After a configured number of consecutive failures, it becomes unhealthy.

The command after CMD can be a shell command or an exec array, and its exit status is the verdict:

Exit codeMeaning
0Healthy - the container is ready for use
1Unhealthy - the container is not working correctly
2Reserved - do not use

Only the exit code matters to the state machine, but any UTF-8 output the command writes to stdout or stderr is captured into the health status and can be read back with docker inspect. Docker stores only the first 4096 bytes, so keep failure output short and diagnostic - one line naming what failed beats a stack trace.

When the health status changes, the daemon emits a health_status event with the new status. That is your hook for alerting on transitions rather than scraping state.

Exec form versus shell form

Both work, and the trade-off is the same as for RUN and CMD:

# Shell form - runs via /bin/sh -c, shell features available
HEALTHCHECK CMD curl -f http://localhost:8080/healthz || exit 1

# Exec form - no shell, exact process, JSON array
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD ["node", "healthcheck.js"]

The exec form is the one to prefer when you can: no shell startup cost every interval, no quoting bugs, and the exit code is unambiguous. Note that the shell form’s || exit 1 is not decoration - curl returns non-zero on connection failure, but many naive shell one-liners end with a command that succeeds regardless, masking the failure. With the exec form, whatever the process returns is the verdict, full stop.

There can only be one HEALTHCHECK instruction in a Dockerfile; if you list more than one, only the last takes effect.

The timing options and their defaults

Five flags can appear before CMD, and the defaults are worth memorizing because they are aggressive in one direction and lazy in the other:

OptionDefaultWhat it controls
--interval=DURATION30sTime between checks, measured from the end of the previous check
--timeout=DURATION30sMax time a single check may run before it is killed and counted as failed
--start-period=DURATION0sGrace window at boot where failures do not count toward retries
--start-interval=DURATION5sCheck frequency during the start period (requires Docker Engine 25.0+)
--retries=N3Consecutive failures needed to declare the container unhealthy

Two details from the reference are easy to miss and matter in production:

The interval clock starts after the previous check completes, not on a fixed schedule. A check that takes 10 seconds with --interval=30s runs every 40 seconds, not every 30. Slow checks therefore drift, and combined with the timeout they consume far more of your budget than the interval suggests.

A check that overruns --timeout is killed with SIGKILL and counts as a failure. That is a hard kill, not a polite cancellation - if your check holds a database connection or a file lock at that moment, it leaks until the container recycles.

The start period deserves its own paragraph because it is the difference between a clean boot and a restart loop. During the start period, healthchecks run at start-interval frequency instead of interval, and probe failures are not counted toward the retry maximum. But - and this is the sharp edge - “if a health check succeeds during the start period, the container is considered started and all consecutive failures will be counted towards the maximum number of retries.” A service that flaps healthy once during boot and then degrades immediately gets no further grace.

A realistic configuration for a JVM service that takes 25 seconds to warm up:

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --start-interval=2s --retries=3 \
  CMD ["java", "-cp", "/app", "HealthProbe", "http://127.0.0.1:8080/readyz"]

The 60-second start period absorbs the warm-up, start-interval=2s means the moment it is actually ready the container flips to healthy instead of waiting out the first 30-second interval, and the 5-second timeout bounds every subsequent probe.

Picking a check that is cheap and meaningful

A healthcheck runs on a timer, forever, inside the same container as your application. Every constraint below follows from that one sentence.

Checks that lie

The cardinal sin is a check that passes while the app is broken. Common versions:

  • Process checks. HEALTHCHECK CMD ps aux | grep -c java (or pgrep, or checking that a PID file exists) only proves the process table is readable. It is precisely the “server stuck in an infinite loop” case Docker’s docs call out - the process is there, it just does nothing.
  • Port-open checks without a request. A TCP connect that is immediately accepted proves the socket is bound, not that the application layer answers. For most servers that is a half-check at best.
  • GET / on a page that never touches your dependencies. If / returns a static splash page from memory, it stays 200 while your database is unreachable. The check must exercise the path that actually matters - typically a dedicated readiness endpoint that verifies the app can reach its critical dependencies, with a tight internal timeout.
  • Checks that return 0 unconditionally in shell form. A forgotten || exit 1, or a pipeline whose last command succeeds, turns your probe into a constant healthy.

Checks that cost too much

The opposite failure is a correct check with unacceptable side effects:

  • Anything that downloads. wget/curl against an external URL - a status page, a CDN, a license server - makes your container’s health depend on a third party’s uptime and your egress path. Probe loopback only. It also quietly reintroduces the network dependency you built the container to escape.
  • Checks that need tools your image does not have. Scratch-based and distroless images have no curl, no wget, no sh. A healthcheck referencing them passes the build (it is just metadata) and fails at runtime forever. Ship a dedicated tiny probe binary in the image, or have the application serve the probe itself.
  • Checks that hit production dependencies at full weight. SELECT 1 against your database is fine; running your migration-status query or a full table scan every 30 seconds is not. The check inherits a share of the very resource it is validating.
  • Checks that write. Anything that creates files, rows, or cache entries on every probe mutates state on a timer. Prefer read-only probes.

The target shape: a probe that runs in single-digit milliseconds, touches only loopback, exercises the real request path, and writes one short line on failure.

What the health status actually affects

Health status is not decoration, but its reach is narrower than people assume:

  • docker ps displays it. The status column shows (healthy) or (unhealthy (3/3)) beside the running state - the first place humans see it.
  • docker inspect exposes it, including the captured probe output, which is how you debug a failed check after the fact.
  • Compose gates startup on it. depends_on with condition: service_healthy makes Compose wait for a healthy verdict before starting dependents. The Compose file can override anything the image declares - interval, timeout, retries, start_period, start_interval, or replace the test outright with test: ["CMD", ...], test: ["CMD-SHELL", ...], or test: ["NONE", ...] / disable: true to switch the image’s check off.
  • The health_status event fires on transitions, which is what you wire into monitoring.

What it does not do: Docker does not restart an unhealthy container. Nothing in the engine reacts to unhealthy by killing or replacing the container. An orchestrator or your own tooling has to act on the status - Compose’s depends_on only consults it during project startup, and even restart: true on a dependency re-runs the dependent service only on explicit Compose restarts of the dependency, not in response to later degradation. If you want self-healing on failed probes, that is a Kubernetes liveness probe or an external supervisor’s job, not a Dockerfile line.

A worked example

An Alpine-based Go service with a real readiness endpoint:

FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/api ./cmd/api

FROM alpine:3.21
COPY --from=build /out/api /usr/local/bin/api
# wget ships with busybox on alpine - no extra packages needed
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --start-interval=2s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
EXPOSE 8080
CMD ["api"]

And the matching Compose override when a downstream service must wait on it:

services:
  api:
    build: .
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
      interval: 15s
      timeout: 3s
      retries: 5
      start_period: 20s
  worker:
    image: my-worker
    depends_on:
      api:
        condition: service_healthy

The image ships a sensible default so docker run gets health tracking with zero config; the Compose file tightens the interval and extends retries for the one deployment where ordering matters. Verify it end to end:

$ docker build -t my-api .
$ docker run -d --name api my-api
$ docker ps
CONTAINER ID   IMAGE    STATUS                   PORTS
a1b2c3d4e5f6   my-api   Up 20 seconds (healthy)  0.0.0.0:8080->8080/tcp
$ docker inspect --format '{{json .State.Health}}' api | jq '.Log[-1].Output'
""

An empty last log entry with a healthy status means the last probe passed silently - exactly what you want.

Checklist

  • Probe a real request path on loopback (a /healthz or /readyz endpoint), not the process table and not /.
  • Use the exec form (CMD ["binary", "args"]) where the image supports it, so the exit code is unambiguous.
  • Set --timeout well below --interval, and assume an overrunning check is SIGKILLed.
  • Give slow-booting services a generous --start-period plus a short --start-interval, and remember one successful probe during that window ends the grace.
  • Keep probe output to one short line; only the first 4096 bytes are stored for docker inspect.
  • Never let a healthcheck touch anything outside the container - no downloads, no external URLs.
  • Confirm the tools your check needs (wget, curl, a probe binary) actually exist in the final image.
  • Remember that unhealthy changes nothing by itself - wire the status into Compose conditions, events, or an orchestrator that acts on it.

Sources: Dockerfile reference - HEALTHCHECK, Compose services reference - healthcheck and depends_on, Compose startup ordering how-to