← Back to DevBytes

Docker Compose Health Checks: Production Guide

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:

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:

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:

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:

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:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles