Health Checks

A Docker health check is a small command Docker runs inside a container to decide whether the application is actually working. This matters because a container can be running while the service inside it is hung, misconfigured, or still starting. Health checks turn that hidden state into visible status: starting, healthy, or unhealthy.

Health checks do not replace logs, metrics, or real monitoring, and they do not magically restart a broken container by themselves. They give Docker and tools such as Docker Compose a reliable signal that a process is alive enough to serve its intended purpose.

Overview: How Health Checks Work

An image is a read-only template made of filesystem layers and metadata. A container is an instance of that image with a thin writable layer, runtime configuration, and one main process. Normally Docker reports container state from the process: if PID 1 is still running, the container is Up. A health check adds a second question: is the application inside the container responding correctly?

You define a health check in a Dockerfile with the HEALTHCHECK instruction, or at runtime with docker run --health-cmd and related flags. Docker stores this command as container configuration. After the container starts, the Docker daemon periodically runs the health command inside the container’s namespaces. That means the command sees the container filesystem, network namespace, environment, and installed tools, just like a process started with docker exec.

The command’s exit code is the contract. Exit code 0 means success and marks the container healthy after the start period has passed. Exit code 1 means failure. Docker also treats command timeouts as failures. Exit code 2 is reserved by Docker and should not be used for normal application checks.

Health state is separate from running state. A web server process may still be alive while its database connection pool is exhausted; Docker can show the container as Up but unhealthy. You can see this in docker ps, docker inspect, Docker Desktop, and Compose output. The health history also records recent check attempts and their output, which is useful when debugging a service that looks alive but does not work.

Health checks are local to one container. In plain Docker Engine, an unhealthy container is not automatically restarted just because the health check fails. Restart policies react mainly to process exits, not health status. Compose can use health checks to wait for dependencies with depends_on conditions, and orchestrators such as Swarm or Kubernetes can use health-like signals for scheduling and replacement, but the exact behavior depends on the platform.

Syntax

HEALTHCHECK [OPTIONS] CMD command
HEALTHCHECK NONE
docker run --health-cmd "command" [HEALTH OPTIONS] IMAGE
Option Meaning
CMD command The command Docker runs inside the container. It should return 0 for healthy and nonzero for unhealthy.
NONE Disables any health check inherited from the base image.
--interval DURATION How often Docker runs the check after the previous check completes. Dockerfile form uses --interval=30s.
--timeout DURATION How long Docker waits before considering one check failed.
--start-period DURATION Grace period for startup. Failures during this period do not count against retries.
--retries N Number of consecutive failures required before the container becomes unhealthy.
--health-cmd Runtime form of the health command for docker run.
--no-healthcheck Runtime option that disables a health check from the image.

Durations use values such as 5s, 30s, or 1m. A good check is fast, deterministic, and specific to the service. A web app usually checks a local HTTP endpoint. A worker might verify it can reach its queue or that its internal status command succeeds.

Examples

Example 1: Add a Health Check in a Dockerfile

FROM python:3.12-alpine

WORKDIR /app
RUN adduser -D appuser
USER appuser

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/', timeout=2)" || exit 1

CMD ["python", "-m", "http.server", "8000"]

Output:

Successfully built 9f2c7c7a2d8a
Successfully tagged health-demo:1.0

This Dockerfile starts a tiny Python HTTP server and asks Docker to probe it through the container’s own loopback interface. The check succeeds only if Python can open http://127.0.0.1:8000/ within two seconds. EXPOSE 8000 is only image metadata and documentation; it does not publish the port to the host. Publishing still requires -p on docker run or ports: in Compose.

Example 2: Build, Run, and Watch Health Status

docker build -t health-demo:1.0 .
docker run -d --name health-demo -p 8000:8000 health-demo:1.0
docker ps --filter "name=health-demo"

Output:

CONTAINER ID   IMAGE             COMMAND                  STATUS                            PORTS                    NAMES
8d9d7c5b4a21   health-demo:1.0   "python -m http.ser..."   Up 8 seconds (health: starting)   0.0.0.0:8000->8000/tcp   health-demo

Immediately after startup, Docker reports health: starting because the --start-period has not finished. After successful checks, the status changes to healthy. If three checks fail in a row after the start period, Docker marks the container unhealthy while the main process may still be running.

Example 3: Inspect the Health Log

docker inspect --format '{{json .State.Health}}' health-demo

Output:

{"Status":"healthy","FailingStreak":0,"Log":[{"ExitCode":0,"Output":""}]}

docker inspect shows the detailed health object. The most important fields are Status, FailingStreak, and Log. The log includes recent probe attempts, exit codes, timestamps, and command output. Keep health-check output short because Docker stores only a limited amount, and it is meant for diagnosis, not application logging.

Example 4: Override Health Settings at Runtime

docker run -d --name runtime-health --health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/', timeout=2)\"" --health-interval 15s --health-timeout 3s --health-retries 2 -p 8001:8000 health-demo:1.0

Output:

f1a9e6c31ed6d1ad6c2ab4034f83db81ebdf028e4a2d16b4f262851798b894e1

Runtime flags can override or add health configuration without changing the image. This is useful for experiments, but production teams usually prefer the Dockerfile or Compose file so the health behavior is versioned with the service. Use --no-healthcheck when an inherited health check is wrong for your use case.

Example 5: Use a Compose Health Check

services:
  web:
    build: .
    image: health-demo:1.0
    ports:
      - "8000:8000"
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/', timeout=2)"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 10s

Output:

NAME          IMAGE             STATUS
web-1         health-demo:1.0   running (healthy)

Compose stores the same idea in YAML. The test array avoids shell quoting problems by passing arguments directly. Compose can also use health checks with dependency conditions so one service waits until another is healthy, which is better than sleeping for an arbitrary number of seconds.

How It Works Step by Step

  1. Docker builds or pulls an image. If the image contains HEALTHCHECK, that instruction is stored as image metadata, not as an extra background process.
  2. You create a container from the image. Docker copies the health configuration into the container’s runtime configuration, where it can be overridden by docker run flags.
  3. The container starts its main process. During --start-period, Docker may run checks, but failures do not count toward the unhealthy threshold.
  4. After each interval, the Docker daemon executes the health command inside the running container. The command uses the container’s filesystem and network namespace, so 127.0.0.1 means the container itself.
  5. Docker waits up to the configured timeout. A success resets the failing streak to zero. A failure increments it after the start period.
  6. When the failing streak reaches --retries, Docker marks the container unhealthy. A later success changes it back to healthy.
  7. docker ps summarizes the health state, and docker inspect exposes the detailed history for debugging.

Because the check runs inside the container, the image must include whatever tool the check needs. If your health command uses curl, the image must install curl. Smaller images are good, but a health check that calls a missing binary will fail every time.

Common Mistakes

Checking the Wrong Address

HEALTHCHECK CMD curl -f http://localhost:8080/health || exit 1

This is wrong if the application listens on port 8000 inside the container. Health checks run inside the container network namespace, so they should use the container port, not the host-published port. The fix is to check the address the service actually listens on internally:

HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/', timeout=2)" || exit 1

Depending on a Tool That Is Not Installed

FROM alpine:3.20
HEALTHCHECK CMD curl -f http://127.0.0.1:8000/ || exit 1

Minimal images often do not include curl. Installing it only for health checks adds size, but calling a missing binary makes the container permanently unhealthy. Use a tool already in the image, install the needed tool intentionally, or add a small application-native status command.

Making the Health Check Too Heavy

HEALTHCHECK --interval=5s CMD python manage.py migrate && python manage.py test

A health check should not run database migrations, full test suites, expensive queries, or anything with side effects. It runs repeatedly for the life of the container. Keep it fast and read-only, such as checking a local HTTP endpoint or a lightweight internal readiness command.

Assuming Unhealthy Means Restarted

docker run -d --restart unless-stopped --name health-demo health-demo:1.0

A restart policy does not normally restart a container only because its health check fails. It restarts containers when the main process exits according to the policy. If you need automated replacement based on health, use an orchestrator or external supervisor that explicitly reacts to health status.

Best Practices

  • Prefer a health check that tests the real service path, such as a local HTTP endpoint, not merely ps or a port-open check.
  • Keep checks fast, deterministic, and side-effect free. They run for the entire lifetime of the container.
  • Use --start-period for applications with slow startup so Docker does not count expected warm-up failures.
  • Set realistic --interval, --timeout, and --retries. Very aggressive checks can create noise or load.
  • Use pinned image tags such as python:3.12-alpine. Avoid latest for repeatable builds and debugging.
  • Make sure the image contains the command used by the health check, or use an application-native command.
  • Keep output short and never print secrets. Secrets do not belong in image layers or health-check output.
  • Remember that EXPOSE is documentation metadata only; it does not publish ports.
  • Use docker inspect when docker ps only tells you that a container is unhealthy.
  • Disable inherited checks with HEALTHCHECK NONE or --no-healthcheck when they are misleading for your image.

Practice Exercises

  1. Create a Dockerfile for a small HTTP service using python:3.12-alpine. Add a health check that probes 127.0.0.1 on the container port. Expected end state: docker ps eventually shows healthy.
  2. Run the same image with a deliberately wrong --health-cmd that checks the wrong port. Inspect .State.Health and identify the failing streak and probe output.
  3. Write a Compose file with one service and a healthcheck section. Hint: use array form for test to avoid quoting issues.

Summary

  • Health checks answer whether the service inside a running container is working, not just whether the process exists.
  • HEALTHCHECK CMD stores a command that Docker runs inside the container on a schedule.
  • Exit code 0 means healthy; nonzero or timeout means a failed check.
  • --interval, --timeout, --start-period, and --retries control timing and failure thresholds.
  • docker ps shows the summary, while docker inspect shows detailed health history.
  • Health checks should be lightweight, local, read-only, and based on the real service behavior.
  • An unhealthy state is a signal; plain Docker does not automatically restart a container just because health failed.