A running container isn’t necessarily a working one. The process may be alive while the app is stuck, still starting, or unable to reach its database. Health checks let Docker test the application itself and report healthy or unhealthy. Compose and orchestrators can then wait for dependencies to be ready, and you can monitor what really matters.
In a Dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:3000/health || exit 1
--interval— time between checks.--timeout— how long a check may take.--start-period— grace period while the app boots; failures don’t count during it.--retries— consecutive failures before the container is markedunhealthy.
The command must exit 0 for healthy, 1 for unhealthy. It runs inside the container, so the tools it uses (curl, wget) must exist in the image. Slim and distroless images often lack them — use wget -q --spider on Alpine, a tiny static health binary, or a check built into your app (node healthcheck.js).
In Docker Compose
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
app:
image: ghcr.io/example/app:1.4
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
depends_on with condition: service_healthy makes Compose wait until the database accepts connections before starting the app — not merely until its container started. This fixes the classic “app crashes on first boot because the DB isn’t ready yet”.
docker compose up --wait waits until all services are healthy — handy in CI and deploy scripts.
Common health commands
| Service | Check |
|---|---|
| PostgreSQL | pg_isready -U postgres |
| MySQL / MariaDB | mysqladmin ping -h localhost (or healthcheck.sh --connect --innodb_initialized in official MariaDB images) |
| Redis | redis-cli ping |
| Web app | curl -fsS http://localhost:PORT/health |
| Nginx | curl -fsS http://localhost/ -o /dev/null |
Checking status
docker ps # STATUS shows (healthy) / (unhealthy)
docker inspect --format '{{.State.Health.Status}}' app
docker inspect --format '{{json .State.Health}}' app | jq # last results with output
What happens when a container is unhealthy?
On plain Docker: nothing automatic — it’s reported, not restarted. (Restart policies only react to the process exiting — see restart policies.) Options:
- Docker Swarm and Kubernetes replace unhealthy tasks/pods automatically.
- A small helper container (e.g.
autoheal) restarts containers marked unhealthy. - Monitor and alert: Uptime Kuma can check Docker container health, and Prometheus exporters expose it.
Designing a good health endpoint
- Fast and cheap — it runs every few seconds. No heavy queries.
- Meaningful — return non-200 when the app truly can’t serve requests (e.g. can’t reach its database).
- Separate liveness from readiness if you can: “process is alive” vs “ready for traffic”. Kubernetes uses both; in Docker, the health check is usually the readiness-style one.
- Don’t cascade — if every service fails health when a shared dependency is down, everything restarts at once. Decide which dependencies are truly fatal.
- No secrets or detailed internals in the response if the endpoint is publicly reachable.
Example (Express):
app.get('/health', async (req, res) => {
try {
await db.query('SELECT 1');
res.status(200).json({ status: 'ok' });
} catch {
res.status(503).json({ status: 'db-unavailable' });
}
});
Troubleshooting
- Always
starting— the start period hasn’t elapsed, or the check never passes; inspect the health log for its output. unhealthybut the app works — the check command is missing in the image (curl: not found), uses the wrong port, or checks a URL that requires authentication.- Health check slows the app — interval too short or the endpoint is expensive.
Related: Dockerfile tutorial and Docker Compose tutorial.