A running container isn't necessarily a working container. Health checks let Docker know when your app is truly alive, and restart policies define what happens when it isn't. Together they form the foundation of self-healing infrastructure.

1. Why Health Checks?

Docker tracks whether a container's main process (PID 1) is running. But a process can be alive while the application inside is completely broken:

  • Deadlock — threads are stuck waiting on each other; the process exists but serves nothing
  • Memory exhaustion — the app is thrashing, responding to no requests
  • Dependency failure — the database connection is gone; the app returns 500 on every request
  • Startup hang — the process started but never finished initialization

Without health checks, Docker reports the container as "running" and load balancers keep sending traffic to a black hole. Health checks close this observability gap.

Process ≠ Application Health

A Java app with a deadlocked thread pool still has PID 1 running. A Node.js server stuck in an infinite loop still shows "Up 3 hours" in docker ps. The process is alive; the application is dead.

2. HEALTHCHECK Instruction

The HEALTHCHECK instruction in a Dockerfile tells Docker how to test whether a container is working:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

Parameters Explained

FlagDefaultPurpose
--interval30sTime between health checks
--timeout30sMax time a single check can take before it's considered failed
--start-period0sGrace period after container start — failures during this window don't count toward retries
--retries3Consecutive failures needed to mark container unhealthy

Health States

A container with a health check moves through three states:

  • starting — container just started; within the start-period grace window
  • healthy — the health check command returned exit code 0
  • unhealthy — the health check failed retries consecutive times
starting healthy unhealthy pass N fails pass N fails after start-period Health Check State Machine
# Disable health check (e.g., in debug images)
HEALTHCHECK NONE

# Override at runtime
docker run --health-cmd="curl -f http://localhost/" \
  --health-interval=10s --health-retries=2 myapp

3. Health Check in Compose

In compose.yaml, health checks are defined with the healthcheck key:

services:
  api:
    image: myapp:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

  worker:
    image: myworker:latest
    depends_on:
      api:
        condition: service_healthy

The depends_on with condition: service_healthy ensures worker won't start until api passes its health check. This gives you proper startup ordering based on actual readiness, not just process start.

Without condition

Plain depends_on: [api] only waits for the container to start, not become healthy. Your worker might connect to a database that hasn't finished initializing yet.

4. Custom Health Check Patterns

HTTP Endpoint (Most Common)

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

Best for web APIs. The /health endpoint should return 200 if the app is ready to serve traffic.

TCP Socket Check

HEALTHCHECK CMD nc -z localhost 5432 || exit 1

Good for databases and services where you just need to confirm the port is accepting connections.

Command Execution

HEALTHCHECK CMD redis-cli ping | grep -q PONG

Use the application's own CLI tool to verify internal state.

Database Connection Test

HEALTHCHECK CMD pg_isready -U postgres || exit 1

PostgreSQL ships pg_isready; MySQL has mysqladmin ping.

Keep Health Checks FAST and CHEAP

A health check that queries every table or runs a full test suite defeats the purpose. Aim for <100ms execution. Check that the app can respond, not that every feature works. A slow health check can itself cause cascading failures under load.

5. Restart Policies Revisited

PolicyBehavior
noNever restart (default). Container stays stopped.
on-failure[:max]Restart only if exit code ≠ 0. Optional max retry count.
alwaysAlways restart regardless of exit code. Also starts on daemon boot.
unless-stoppedLike always, but won't restart if manually stopped before daemon restart.

Restart Policies + Health Checks: The Misconception

Docker Does NOT Auto-Restart Unhealthy Containers

A common misconception: restart: always will restart a container marked unhealthy. It won't. Docker's restart policy only triggers when the container process exits. An unhealthy container with a running process stays running (and unhealthy) forever — unless an orchestrator acts on it.

This means:

  • Standalone Docker — health checks are informational only (visible in docker ps, events, inspect)
  • Docker Swarm — the orchestrator kills and replaces unhealthy tasks
  • Kubernetes — liveness probes trigger pod restarts
  • Compose + autoheal — third-party tools like willfarrell/autoheal watch for unhealthy containers and restart them

6. Self-Healing Patterns

True self-healing requires three layers working together:

Self-Healing Loop Health Check Unhealthy Restart Healthy ✓ fail orchestrator re-check pass

Layer 1: Application Resilience

The app itself handles transient failures — retries, circuit breakers, graceful degradation.

Layer 2: Container Restart

# Compose: restart on process crash
services:
  api:
    restart: on-failure:5
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 15s
      retries: 3

Layer 3: Orchestrator Recovery

# Kubernetes: liveness probe triggers pod restart
livenessProbe:
  httpGet:
    path: /health
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 15
  failureThreshold: 3
🏭 Industry: Kubernetes Three-Probe Pattern

Kubernetes distinguishes three types of probes:

  • Startup Probe — "Has the app finished initializing?" Disables liveness/readiness checks until it passes. For slow-starting apps.
  • Liveness Probe — "Is the app still alive?" Failure → pod restart. Catches deadlocks and hangs.
  • Readiness Probe — "Can it handle traffic?" Failure → remove from Service endpoints (no restart). For temporary overload or dependency issues.

Don't conflate them. A liveness probe that checks the database will restart your pod every time the DB has a hiccup — use readiness for that.

7. Monitoring Health

Inspect Container Health

# Current health status
docker inspect --format='{{.State.Health.Status}}' mycontainer

# Full health details (last 5 check results)
docker inspect --format='{{json .State.Health}}' mycontainer | jq .

# Watch health events in real-time
docker events --filter event=health_status

Compose Health Overview

# See health status of all services
docker compose ps
# NAME    IMAGE    COMMAND    SERVICE    STATUS           PORTS
# api     myapp    ...        api        Up (healthy)     0.0.0.0:3000->3000/tcp
# db      pg:16    ...        db         Up (unhealthy)   5432/tcp

Integration with Monitoring

Health check results feed into monitoring stacks:

  • Prometheus — export container health as a metric via cAdvisor
  • Docker events — stream to Elasticsearch/Loki for alerting
  • Uptime monitors — external tools (Pingdom, UptimeRobot) hit the same /health endpoint from outside
🐧 Not Just Docker

Podman supports the same HEALTHCHECK instruction and podman healthcheck run command. For rootless containers managed by systemd, you can combine Podman health checks with systemd's Restart=on-failure and ExecStartPre for dependency ordering — achieving Compose-like self-healing without a daemon.

Hands-On Tasks

🔬 Task 1: Observe Health State Transitions

Add a health check to a container and watch it transition through states:

# Run nginx with a health check
docker run -d --name healthy-nginx \
  --health-cmd="curl -f http://localhost/ || exit 1" \
  --health-interval=5s \
  --health-retries=3 \
  --health-start-period=3s \
  nginx:alpine

# Watch it transition from 'starting' to 'healthy'
watch -n1 "docker inspect --format='{{.State.Health.Status}}' healthy-nginx"

# Now break it — block the port inside the container
docker exec healthy-nginx sh -c "nginx -s stop"

# Watch it go 'unhealthy' (process exits, container stops)
# Try with a subtler failure:
docker run -d --name broken-app \
  --health-cmd="curl -f http://localhost:8080/ || exit 1" \
  --health-interval=5s \
  --health-retries=2 \
  nginx:alpine
# nginx listens on 80, not 8080 — health check will fail
# Observe: container is "running" but "unhealthy"

docker inspect --format='{{json .State.Health}}' broken-app | jq .

# Cleanup
docker rm -f healthy-nginx broken-app

🔬 Task 2: Compose Startup Ordering with Health Checks

Create a setup where Service B waits for Service A to be healthy:

# compose.yaml
services:
  database:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

  api:
    image: nginx:alpine  # stand-in for your app
    depends_on:
      database:
        condition: service_healthy
    ports:
      - "8080:80"
# Start and observe ordering
docker compose up

# You'll see:
# database: starting...
# database: healthy
# api: starting (only AFTER database is healthy)

# Verify
docker compose ps  # both show "(healthy)" or "Up"
docker compose down

Interactive Quizzes

Quiz 1: Health Check States

A container has --retries=3. The health check fails twice, then passes on the third attempt. What is the container's health status?

Quiz 2: Restart Policy Behavior

A container has restart: always and a HEALTHCHECK. The health check reports "unhealthy" but the process is still running. What does Docker do?

Quiz 3: Liveness vs Readiness

Your Kubernetes pod's database connection drops temporarily. Which probe type should detect this and what should happen?

Key Takeaways

  • Process alive ≠ app healthy — always define health checks for production containers
  • HEALTHCHECK gives Docker three states: starting → healthy ↔ unhealthy
  • Keep checks fast — under 100ms, testing only critical-path readiness
  • Restart policies react to process exit, NOT health status — don't confuse the two
  • Orchestrators close the gap — Swarm/K8s kill and replace unhealthy containers
  • Compose ordering — use depends_on: condition: service_healthy for true startup sequencing
  • Kubernetes three-probe pattern — startup, liveness, readiness each serve a different purpose
  • Self-healing is layered — app resilience + restart policies + orchestrator recovery