Docker Compose Healthcheck: How to Configure, Test, and Troubleshoot Container Health

Docker Compose Healthcheck: How to Configure, Test, and Troubleshoot Container Health

A container that shows Up in docker ps isn’t necessarily working. The process might be running but the application inside could be stuck, returning errors, or still initializing. Docker health checks fill this gap – they tell you (and Docker) whether a container is actually healthy.

This guide is part of our Docker containers complete guide and fits alongside Docker Compose Up, Compose Restart Policy, and the Compose shutdown cluster (Stop, Down, Down -v).

How Docker Health Checks Work

A health check is a command that Docker runs inside the container at a set interval. Based on the exit code, Docker assigns one of three states:

State Meaning
starting Initial grace period; failures don’t count yet
healthy The last N checks passed (where N = `retries`)
unhealthy The last N checks failed consecutively

Exit codes are simple:

0 – success, container is healthy

1 – failure, container is unhealthy

2 – reserved (don’t use this)

Docker emits a health_status event on every state change, which orchestrators and monitoring tools can react to. The last 5 check outputs (first 4096 bytes each) are stored and visible via docker inspect.

HEALTHCHECK in a Dockerfile

Add a HEALTHCHECK instruction to bake health monitoring directly into your image:

FROM nginx:alpine

HEALTHCHECK \
  --interval=30s \
  --timeout=5s \
  --start-period=10s \
  --retries=3 \
  CMD curl -f http://localhost/ || exit 1Code language: Dockerfile (dockerfile)

Health check options

Option Default What it does
`–interval` 30s Time between checks
`–timeout` 30s Max time a single check can take
`–start-period` 0s Grace period before failures count
`–start-interval` 5s Check interval during start period
`–retries` 3 Consecutive failures before marking unhealthy

To disable a health check inherited from a base image, use HEALTHCHECK NONE.

When to use Dockerfile vs Compose: Put the health check in the Dockerfile if every instance of the image should be monitored the same way (production, staging, local dev). Use Compose when the check varies by environment – for example, a different port or endpoint per deployment.

Healthcheck in Docker Compose

Compose health checks work the same way but are defined in docker-compose.yml:

services:
  web:
    image: nginx:alpine
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40sCode language: YAML (yaml)

Two formats for the test command:

CMD (exec form): ["CMD", "curl", "-f", "http://localhost"] – no shell, faster, no variable expansion

CMD-SHELL: ["CMD-SHELL", "curl -f http://localhost || exit 1"] – runs inside /bin/sh, supports pipes and $VARIABLES

Use CMD-SHELL when you need shell features like || exit 1, pipes, or environment variable interpolation. Use CMD (exec form) for simple commands where you want to avoid shell overhead.

Full Compose example with depends_on

services:
  db:
    image: postgres:18
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    ports:
      - "3000:3000"Code language: YAML (yaml)

Here app waits for PostgreSQL to be healthy before starting, while Redis only needs to be started (not necessarily healthy).

depends_on with service_healthy

By default, depends_on only controls startup order – Compose waits for a dependency to be *running*, not *ready*. A database container might be running but still initializing its data directory for 20 more seconds. Without a health check, your app connects too early and fails.

The condition attribute fixes this:

Condition Behavior
`service_started` Wait for the container to start (default)
`service_healthy` Wait for the health check to pass
`service_completed_successfully` Wait for a one-shot container to exit with code 0

You can also add restart: true under depends_on (Compose v2.17.0+). When the dependency restarts, the dependent service restarts too – useful for keeping connection pools fresh.

Practical Healthcheck Recipes

Web service (HTTP)

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
  interval: 30s
  timeout: 5s
  retries: 3Code language: YAML (yaml)

The -f flag makes curl exit with code 22 on HTTP errors (4xx/5xx). Combine with a dedicated /health or /healthz endpoint that verifies database and cache connectivity, not just “the process is running.”

Web service (wget, for Alpine without curl)

healthcheck:
  test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health > /dev/null 2>&1 || exit 1"]
  interval: 30s
  timeout: 10s
  retries: 3Code language: YAML (yaml)

Use 127.0.0.1 instead of localhost in Alpine – Alpine’s /etc/hosts can resolve localhost to ::1 (IPv6), while your app may only bind to 0.0.0.0 (IPv4).

PostgreSQL

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
  interval: 10s
  timeout: 10s
  retries: 5
  start_period: 30sCode language: YAML (yaml)

Note the double $$ – Compose uses $ for variable interpolation. $$ escapes it so the literal $POSTGRES_USER reaches the shell inside the container.

Redis

healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 10s
  timeout: 5s
  retries: 3Code language: YAML (yaml)

Redis PING returns PONG when the server is ready. Minimal, fast, and reliable.

TCP port check (no extra tools needed)

healthcheck:
  test: ["CMD-SHELL", "timeout 5 bash -c '</dev/tcp/localhost/5432' || exit 1"]
  interval: 30s
  timeout: 10s
  retries: 3Code language: YAML (yaml)

Uses bash’s built-in TCP redirection. Works when you don’t have curl, wget, pg_isready, or redis-cli in the image – but requires bash (not present in Alpine by default).

Reading Health Status

docker ps

The STATUS column shows health state directly:

$ docker ps
CONTAINER ID   STATUS                    ...
a1b2c3d4e5f6   Up 2 minutes (healthy)   ...Code language: Bash (bash)

docker inspect

Get detailed health info as JSON:

docker inspect --format='{{json .State.Health}}' my-container | jqCode language: Bash (bash)

Output:

{
  "Status": "healthy",
  "FailingStreak": 0,
  "Log": [
    {
      "Start": "2026-06-16T10:15:00.123456Z",
      "End": "2026-06-16T10:15:00.234567Z",
      "ExitCode": 0,
      "Output": "  % Total    % Received ...\n"
    }
  ]
}Code language: JSON / JSON with Comments (json)

The Log array holds the last 5 check results. FailingStreak tells you how many consecutive failures have occurred – a spike here often signals a real problem.

Quick one-liner for health status only

docker inspect --format='{{.State.Health.Status}}' my-containerCode language: Bash (bash)

Returns healthy, unhealthy, or starting.

Common Mistakes and How to Fix Them

Missing curl in Alpine images

Problem: Health check uses curl but the Alpine image doesn’t include it. The container shows unhealthy with error curl: not found.

Fix – install curl in your Dockerfile:

FROM node:alpine
RUN apk add --no-cache curlCode language: Dockerfile (dockerfile)

Alternative – use wget (included in Alpine by default):

healthcheck:
  test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health > /dev/null || exit 1"]Code language: YAML (yaml)

start_period too short

Problem: Your app needs 45 seconds to start but start_period is set to 10s. The container gets marked unhealthy before initialization finishes.

Fix: Set start_period to slightly more than your app’s worst-case startup time:

healthcheck:
  start_period: 60s
  interval: 10sCode language: YAML (yaml)

If the health check passes before start_period ends, the container switches to healthy immediately – the grace period is a maximum, not a minimum.

localhost resolves to IPv6 in Alpine

Problem: curl http://localhost:3000 fails because Alpine resolves localhost to ::1 (IPv6), but your app listens on IPv4.

Fix: Use 127.0.0.1 explicitly:

healthcheck:
  test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/health"]Code language: YAML (yaml)

docker-compose.yml version field removed

Problem: The top-level version field is obsolete in modern Compose V2. The field is ignored, and health checks plus depends_on with condition work without it.

Fix: Remove the version: line. Modern Compose ignores it.

FAQ

What’s the difference between HEALTHCHECK in Dockerfile and healthcheck in Compose?

Dockerfile HEALTHCHECK bakes the health check into the image — every container from that image gets it. Compose healthcheck overrides or adds a health check for a specific service in a specific deployment. Use Compose when the check differs per environment (staging port vs production port).

Does an unhealthy container get restarted automatically?

No — Docker does not restart a container just because it becomes unhealthy. The health status is informational. To auto-restart unhealthy containers, use an external tool like willfarrell/autoheal or Kubernetes (which has liveness probes for exactly this). You can also combine restart: unless-stopped with health checks, but unless-stopped only reacts to the main process exiting — not to health status.

How often should I run health checks?

The default interval is 30 seconds. For critical services, 10–15 seconds is reasonable. Avoid running checks every 1–2 seconds — each check spawns a process, and excessive checks add CPU overhead. Match the interval to how fast you need to detect failures.

Can I use an HTTP endpoint that checks database connectivity?

Yes, and you should. A /health endpoint that returns 200 because the web framework is running doesn\u2019t tell you if the database is reachable. Build a /healthz endpoint that pings the database, cache, and any other critical dependency. Return 200 only when everything is connected.

Why does my health check need || exit 1?

The health check command itself might return a non-zero exit code on failure (like curl with -f), but if you\u2019re chaining commands or using a shell script, the exit code of the pipeline or script might not propagate. Adding || exit 1 ensures any non-zero exit becomes code 1, which Docker interprets as unhealthy.

Sergio Bremming Avatar

Leave a Reply

Your email address will not be published. Required fields are marked *