Understanding Docker Compose Health Checks
Docker Compose health checks are a built-in mechanism that allows you to define how Docker should verify whether a container is truly "healthy" and ready to serve requests. Unlike a simple "running" status, a health check probes the application inside the container at regular intervals and reports back whether the service is operational. This goes far beyond the basic process-level check Docker performs by default—where a container is considered "up" simply because its main process hasn't exited.
A health check in Docker Compose is defined using the healthcheck key within a service definition. It consists of a command (or an array of command arguments), along with timing parameters that control how often the check runs, how long to wait before the first check, and how many consecutive failures constitute an unhealthy state. When a container fails its health check, Docker Compose can report this status, and orchestrators or reverse proxies can make informed decisions about routing traffic.
What Actually Happens When a Health Check Runs
When you define a health check, Docker periodically executes the specified command inside the container. The exit code of that command determines the result:
- Exit code 0 — The container is healthy
- Exit code 1 — The container is unhealthy
- Exit code 2 — Reserved and should not be used in custom commands
Docker tracks the state internally as starting, healthy, or unhealthy. The starting state is the initial status during the grace period defined by start_period or while the first health check is still running. Only after the first successful health check does the container transition to healthy. This distinction is critical for production deployments where services may have lengthy initialization phases.
Why Health Checks Matter in Production
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →In production environments, the difference between a container that is "running" and one that is truly "ready" can mean the difference between seamless user experience and cascading failures. Here are the core reasons health checks are essential:
Preventing the "Running but Broken" Problem
Consider a Node.js application whose process is alive but whose event loop is blocked, or a database container that is up but refusing connections due to corruption. Without health checks, your load balancer or orchestrator continues to send traffic to these broken instances. A properly configured health check catches this scenario and allows automated recovery or traffic removal.
Ordered Service Startup with Dependencies
Docker Compose's depends_on directive, by default, only waits for containers to start—not for them to be ready. A database container may be "started" but still performing its initial setup for several seconds. Without health checks, your application container might attempt to connect prematurely and crash. By combining health checks with depends_on using the condition: service_healthy option, you enforce strict readiness ordering.
Zero-Downtime Deployments and Rolling Updates
In orchestrated environments (Docker Swarm, Kubernetes-like setups), health checks inform the orchestrator when a new container version is ready to receive traffic. This prevents routing to partially deployed services during rolling updates. The orchestrator waits for the health check to pass on new containers before removing old ones.
Self-Healing Infrastructure
When combined with restart policies, health checks enable self-healing. A container that consistently fails its health check will be reported as unhealthy, and depending on your orchestration layer, it can be automatically restarted or replaced. This reduces mean time to recovery (MTTR) without human intervention.
Observability and Alerting
Health check status is exposed via docker inspect and Docker events, making it easy to integrate with monitoring systems. You can alert on prolonged unhealthy states, track health check duration trends, and gain early warning of degraded services before users notice.
How to Use Health Checks in Docker Compose
Basic Syntax
The health check configuration lives directly under a service definition in your docker-compose.yml file. Here is the full set of available parameters:
services:
my-service:
image: my-app:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s
start_interval: 5s
Let's break down each parameter:
test— The command to execute. It can be a string in shell format or an array in exec format. The array format is strongly recommended for production to avoid shell parsing issues.interval— How often to run the health check after the first successful check. Default is 30 seconds.timeout— Maximum time the check command is allowed to run before it's considered failed. Default is 30 seconds.retries— Number of consecutive failures required to mark the container as unhealthy. Default is 3.start_period— Grace period during which failures are not counted toward the retry limit. Useful for slow-starting applications. Default is 0 seconds.start_interval— Interval between health checks during the start period. Allows more frequent checks during initialization without affecting the steady-state interval.
Health Check Command Formats
The test field supports two formats. The exec format (array) is preferred because it bypasses the shell and avoids signal-handling issues:
# Exec format (recommended)
test: ["CMD", "/usr/local/bin/health-check.sh"]
# Shell format (use only if you need shell features)
test: curl -f http://localhost:8080/health || exit 1
When using the exec format, the first element must be CMD (to run a command directly) or CMD-SHELL (to run a command through the container's default shell). Use CMD whenever possible.
Real-World Health Check Examples
Web Application with an HTTP Health Endpoint
services:
api:
image: my-api:2.4.1
ports:
- "3000:3000"
healthcheck:
test: ["CMD", "curl", "-f", "-s", "http://localhost:3000/healthz"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
This example uses curl inside the container to hit a dedicated health endpoint. The -f flag causes curl to exit with a non-zero status on HTTP errors, and -s suppresses progress output. The start_period of 10 seconds accounts for application boot time.
PostgreSQL Database
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: securepassword
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d mydb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
The pg_isready utility is specifically designed for health checking PostgreSQL. It connects to the database and verifies readiness without consuming significant resources. The start_period is set to 30 seconds because PostgreSQL initialization can be lengthy, especially on first run with volume setup.
Redis with Custom Script
services:
redis:
image: redis:7
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
Redis provides a built-in PING command that responds with PONG. The redis-cli ping approach is lightweight and official.
Node.js Application with Custom Health Script
Sometimes you need more sophisticated health logic. You can ship a dedicated script inside your Docker image:
#!/bin/sh
# health-check.sh - placed in the image at /usr/local/bin/
# Check if the main process is still running
if ! pgrep -x "node" > /dev/null; then
exit 1
fi
# Check if the health endpoint responds
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health)
if [ "$response" -ne 200 ]; then
exit 1
fi
exit 0
services:
app:
build: .
healthcheck:
test: ["CMD", "/usr/local/bin/health-check.sh"]
interval: 20s
timeout: 8s
retries: 4
start_period: 15s
Using Health Checks for Service Dependencies
Docker Compose version 3.x supports conditional dependencies with condition: service_healthy. This ensures that dependent services wait for the health check to pass before starting:
services:
database:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser"]
interval: 5s
timeout: 5s
retries: 10
start_period: 20s
migration:
image: my-migration-tool:latest
depends_on:
database:
condition: service_healthy
# This container only starts after database is healthy
api:
image: my-api:latest
depends_on:
database:
condition: service_healthy
migration:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 15s
timeout: 5s
retries: 3
ports:
- "8080:8080"
Notice the chained dependencies: the API waits for both the database to be healthy and the migration to complete successfully. The service_completed_successfully condition is different from service_healthy—it applies to short-lived containers that exit (like one-off migration tasks) rather than long-running services.
Disabling Health Checks
If you need to temporarily disable health checks (for debugging or when inheriting an image that defines them), you can override with:
services:
debug-service:
image: some-image-with-healthcheck
healthcheck:
disable: true
Best Practices for Production Health Checks
1. Prefer Dedicated Health Endpoints Over Simple TCP Checks
A common pitfall is using a TCP port check like test: ["CMD", "nc", "-z", "localhost", "8080"]. This only verifies that a port is open—not that the application can actually respond to business logic. A port may be open while the application is in a broken state. Always prefer an application-level health endpoint that verifies critical dependencies (database connections, cache availability, external services) and returns an appropriate HTTP status code.
Design your health endpoint to be lightweight but meaningful:
// Example Node.js health endpoint
app.get('/healthz', async (req, res) => {
try {
// Quick connectivity check to critical dependencies
await Promise.race([
db.ping(),
redis.ping(),
]);
res.status(200).json({ status: 'healthy' });
} catch (err) {
res.status(503).json({ status: 'unhealthy', reason: err.message });
}
});
2. Keep Health Checks Lightweight
Health checks run frequently—potentially every few seconds. A heavy check that performs complex computations, queries large datasets, or stresses external services can cause a "thundering herd" problem where health checks themselves degrade performance. Keep checks fast (under 5 seconds is ideal), minimize I/O, and avoid cascading to external services unless absolutely necessary.
3. Tune Timing Parameters to Your Application's Reality
Generic defaults rarely fit production workloads. Consider these guidelines:
- Fast-starting services (Go, Rust apps):
start_period: 5s,interval: 15s - Moderate-starting services (Node.js, Python):
start_period: 15-30s,interval: 20-30s - Slow-starting services (JVM apps, databases):
start_period: 40-90s,interval: 30s - Critical infrastructure (databases, message brokers):
interval: 5-10sfor faster failure detection
Use start_interval to probe more aggressively during the startup phase without affecting the steady-state interval:
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
start_interval: 5s # Check every 5s during startup
4. Avoid Shell Complexity in Test Commands
Shell-based health checks introduce unnecessary complexity: signal handling quirks, escaping issues, and reliance on shell availability in the image. Use exec format with direct binary invocations whenever possible. If you must use shell features (pipes, redirection), use CMD-SHELL explicitly and keep the logic minimal.
Bad example (fragile escaping):
test: ["CMD-SHELL", "curl -f http://localhost:8080/health | grep -q 'ok' || exit 1"]
Better approach—move the logic into a dedicated script inside the image:
test: ["CMD", "/app/health-check.sh"]
5. Align Restart Policies with Health Check Outcomes
Health checks report status but don't automatically restart containers. You need to configure restart policies in conjunction:
services:
critical-service:
image: my-app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 15s
timeout: 5s
retries: 3
restart: unless-stopped
# Or for critical services:
restart: always
In Docker Swarm mode, services with health checks that fail will be automatically replaced by the orchestrator. In plain Docker Compose, you need the restart policy to handle container-level failures, while health check status informs external tooling.
6. Use Start Period Generously for First-Time Initialization
Containers running database migrations, downloading assets, or warming caches on first startup can take much longer than subsequent starts. The start_period parameter exists specifically to absorb this variability without marking the container unhealthy prematurely. Err on the side of generosity—a longer start period has no penalty after the service is healthy, but a too-short start period causes unnecessary failure cascades.
7. Monitor Health Check Metrics in Production
Health check results should be observable. Export health status to your monitoring stack:
# Query health status programmatically
docker inspect --format='{{json .State.Health}}' my-container | jq .
Output example:
{
"Status": "healthy",
"FailingStreak": 0,
"Log": [
{
"Start": "2025-01-15T10:23:45.123456789Z",
"End": "2025-01-15T10:23:45.456789123Z",
"ExitCode": 0,
"Output": "Health check passed"
}
]
}
Integrate this with log aggregation systems, track FailingStreak trends, and alert on transitions to unhealthy status. Many container monitoring platforms (Datadog, Prometheus + cAdvisor, Grafana) can ingest Docker health check events natively.
8. Test Health Checks in CI/CD Pipelines
Health check configurations are code and should be tested like code. In your CI pipeline, spin up the Docker Compose stack and verify that all services reach the healthy state within expected time bounds:
#!/bin/bash
# integration-test-health.sh
docker compose up -d
# Wait for all services to be healthy (with timeout)
timeout=120
elapsed=0
while [ $elapsed -lt $timeout ]; do
unhealthy=$(docker ps --format '{{.Names}}' --filter "health=unhealthy" | wc -l)
if [ "$unhealthy" -eq 0 ]; then
echo "All services healthy after ${elapsed}s"
exit 0
fi
sleep 5
elapsed=$((elapsed + 5))
done
echo "Timeout: services did not become healthy within ${timeout}s"
docker ps --filter "health=unhealthy"
exit 1
9. Be Mindful of Health Check Image Requirements
The health check command runs inside the container, so it must have access to the necessary tools. If your production image is minimal (distroless, alpine slim, scratch-based), you may not have curl, wget, or even a shell. Plan accordingly:
- For minimal images, include a static binary like
curlin the image or use a language-native health check utility - For Go applications, compile a small health-check binary and include it in the image
- For scratch-based images, consider using the
HEALTHCHECKDockerfile instruction with a statically compiled checker
10. Layer Health Checks: Readiness vs. Liveness
In advanced production setups, distinguish between readiness probes (am I ready to serve traffic?) and liveness probes (am I still alive, or should I be restarted?). Docker's health check serves both purposes, but you can implement differentiation in your application:
- Liveness endpoint (
/livez): Simple check that the process is responsive. Returns 200 as long as the event loop isn't blocked. Use a short interval. - Readiness endpoint (
/readyz): Verifies all dependencies are available. Returns 200 only when fully ready. Use for initial startup gating.
In Docker Compose, you typically use the readiness check for the healthcheck definition (since it gates dependencies) and implement liveness separately via restart policies or orchestrator features.
Health Checks in Dockerfile vs. Docker Compose
Health checks can be defined in two places: the Dockerfile with the HEALTHCHECK instruction, or in the Docker Compose file. Understanding when to use each is important for production hygiene.
Dockerfile HEALTHCHECK example:
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
Rules of thumb:
- If the health check logic is universal to the image (every instance needs the same check), define it in the Dockerfile. This makes the image self-describing and portable.
- If the health check depends on deployment-specific configuration (different ports, environment variables, timing tuned for a particular stack), override or define it in Docker Compose. Docker Compose health checks take precedence over Dockerfile definitions.
- For production deployments, prefer Docker Compose definitions because they give you environment-specific control without rebuilding images.
Conclusion
Docker Compose health checks transform containers from opaque process wrappers into observable, self-aware service units. They bridge the gap between "container started" and "application ready," enabling reliable service ordering, self-healing deployments, and meaningful production monitoring. By defining thoughtful health check commands, tuning timing parameters to match your application's real startup behavior, and integrating health status into your observability stack, you build a foundation for resilient containerized systems. The best health checks are lightweight, application-aware, and tested in CI just like any other critical infrastructure code. Start with simple HTTP-based checks, iterate based on production behavior, and never rely solely on whether a container is running—always verify that it's actually working.