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.
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
| Flag | Default | Purpose |
|---|---|---|
--interval | 30s | Time between health checks |
--timeout | 30s | Max time a single check can take before it's considered failed |
--start-period | 0s | Grace period after container start — failures during this window don't count toward retries |
--retries | 3 | Consecutive 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
retriesconsecutive times
# 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.
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.
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
| Policy | Behavior |
|---|---|
no | Never restart (default). Container stays stopped. |
on-failure[:max] | Restart only if exit code ≠ 0. Optional max retry count. |
always | Always restart regardless of exit code. Also starts on daemon boot. |
unless-stopped | Like always, but won't restart if manually stopped before daemon restart. |
Restart Policies + Health Checks: The Misconception
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/autohealwatch for unhealthy containers and restart them
6. Self-Healing Patterns
True self-healing requires three layers working together:
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
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
/healthendpoint from outside
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_healthyfor 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