← Back to DevBytes

Docker Compose Health Checks: Best Practices and Common Pitfalls

What Are Docker Compose Health Checks?

Health checks in Docker Compose provide a built-in mechanism to verify whether a containerized service is truly ready to accept work. They go far beyond the basic container “state” (running vs. stopped) and allow you to define a custom command that periodically tests the internal health of your application. In Docker Compose, health checks are defined directly inside the service block using the healthcheck property. They rely on the same underlying Docker HEALTHCHECK instruction, but Compose adds orchestration superpowers — like the ability to delay startup dependencies until a service becomes healthy.

A health check works by executing a command inside the container at regular intervals. The command must return an exit code of 0 (healthy) or 1 (unhealthy). If it times out or fails a certain number of times in a row, Docker marks the container as unhealthy. Compose then uses this status to control startup order, restart policies, and more.

Why Health Checks Matter

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In distributed applications, simply launching a container does not mean it’s ready to serve traffic. A database may be running but still performing initial data setup, a web server may have the process alive but not yet listening on its port. Relying on container startup alone leads to race conditions, cryptic connection errors, and flaky deployments. Health checks solve these problems by providing explicit readiness signals.

Key benefits include:

How to Define Health Checks in Docker Compose

The syntax for a health check in Docker Compose is straightforward. Inside a service definition, add the healthcheck key with the following sub-options:

Below is a practical Compose file that starts a PostgreSQL database and a Python web service. The web service only starts after the database becomes healthy:

version: "3.9"
services:
  db:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  web:
    build: .
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

In this example, pg_isready is a dedicated PostgreSQL tool that returns exit code 0 only when the database is fully up and accepting connections. The start_period of 30 seconds gives PostgreSQL time to initialize before the first failed check counts against the retry limit. The web service will not start until the db service’s health status is healthy.

To verify health status manually, you can run docker compose ps and look for the Health column, or inspect the container with docker inspect <container> and examine the State.Health object.

Best Practices for Docker Compose Health Checks

1. Use a dedicated, lightweight health endpoint

For HTTP services, expose a specific route like /health that performs minimal internal checks (e.g., database connectivity, essential dependency availability) and returns a non‑200 status on failure. Avoid hitting a heavy landing page or running expensive queries. The health check should be fast and cheap to execute.

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
  interval: 15s
  timeout: 5s
  retries: 3

2. Choose the right test form (array vs. shell)

The array form ["CMD", "executable", "arg1"] does not invoke a shell, making it more secure and avoiding shell parsing surprises. Use CMD-SHELL only when you need shell features like pipes or environment variable expansion. Even then, prefer passing the shell command as a single string in the array: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"].

3. Set realistic intervals, timeouts, and start periods

Don’t use overly aggressive intervals that could overload the container’s CPU or network. A typical pattern is:

4. Distinguish between “readiness” and “liveness”

A readiness check determines when a service can begin accepting work (used by depends_on). A liveness check detects deadlocked or broken processes that should be restarted. In Docker Compose, the health check serves both purposes, but you can model them by using different commands for different services. For example, a database might use pg_isready for readiness, while a separate monitoring service runs a deeper query. For most Compose setups, a single well‑designed health check that verifies both the process and its key dependencies works fine.

5. Leverage health checks to prevent startup races

Always combine health checks with depends_on using the service_healthy condition instead of the default service_started. The latter only waits for the container to start, not for the application to be ready, which can lead to “connection refused” errors.

depends_on:
  database:
    condition: service_healthy

6. Avoid dependencies on external services

Health checks should validate the internal state of the container, not reach out to external APIs (like calling an internet health endpoint). If the external network is slow or down, your container will be marked unhealthy even though the application itself works fine. Keep checks local to localhost or the container’s own filesystem.

7. Use health check logs for debugging

Health check output is captured by Docker and viewable in docker inspect under Health.Log. You can also log results from inside the check command (e.g., by appending >> /var/log/healthcheck.log 2>&1). This is invaluable for troubleshooting silent failures.

Common Pitfalls and How to Avoid Them

Pitfall 1: Health check command that never exits

A command like tail -f /dev/null or a long-running process will cause the health check to hang until the timeout expires, always resulting in unhealthy status. Always use commands that terminate quickly.

# Wrong – hangs forever
healthcheck:
  test: ["CMD", "tail", "-f", "/dev/null"]

# Correct
healthcheck:
  test: ["CMD", "pg_isready", "-U", "postgres"]

Pitfall 2: Using depends_on without condition

By default, depends_on only waits for the container to start, not for its health check to pass. This often leads to errors like “cannot connect to database” because the DB container is running but not yet accepting connections. Always specify condition: service_healthy when readiness matters.

# Wrong – starts web immediately after db container starts
depends_on:
  - db

# Correct
depends_on:
  db:
    condition: service_healthy

Pitfall 3: Forgetting start_period for slow-starting services

Containers that require substantial initialization (e.g., loading large datasets, running database migrations) will fail the first few health checks. Without a start_period, these failures count toward the retry limit, potentially marking the container unhealthy before it ever finishes starting. Set start_period generously.

# Without start_period, early failures may cause unnecessary restart
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080"]
  interval: 5s
  timeout: 3s
  retries: 3
  # start_period omitted – dangerous for slow startup!

# Better
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080"]
  interval: 10s
  timeout: 5s
  retries: 5
  start_period: 60s

Pitfall 4: Health check command missing required utilities

If your container is minimal (e.g., based on a slim Alpine image), commands like curl, wget, or even bash may be absent. Use the tools that are available or install them in the Dockerfile. For HTTP checks, consider using a small dedicated binary like wget or even a custom Go/Rust health checker baked into the image.

# Ensure curl is installed in your Dockerfile
FROM alpine:3.18
RUN apk add --no-cache curl

Pitfall 5: Using shell form incorrectly

The CMD-SHELL form runs inside /bin/sh. If your container uses a different shell or doesn’t have a shell at all, it will fail. Additionally, forgetting to escape variables or using complex pipelines can lead to unexpected behavior. Test your command interactively inside a running container before committing it to the Compose file.

Pitfall 6: Health check that returns success too early

A health check that simply checks if a process exists (e.g., ps aux | grep mysqld) may succeed before the service is actually ready. For databases, always use native readiness tools (pg_isready, mysqladmin ping, redis-cli ping). For custom apps, implement a /health endpoint that verifies connectivity to all required downstream services before returning 200.

Pitfall 7: Ignoring health check state in orchestration scripts

Even if you define health checks, scripts that use docker-compose up -d often assume services are ready after a fixed sleep. Instead, use docker compose up --wait (available in Docker Compose v2) or poll the health status with docker compose ps --format json until all expected services are healthy. This ensures automation truly respects readiness.

Conclusion

Docker Compose health checks turn containers from simple running processes into well‑defined, verifiable service instances. By implementing them correctly — choosing the right command, tuning intervals, leveraging start_period, and always pairing with condition: service_healthy — you eliminate race conditions, improve reliability, and make your local development and CI pipelines behave more like production. Avoid the common traps of hanging commands, missing utilities, and overly simplistic checks. When done right, health checks become a silent guardian that ensures your multi‑container stack starts cleanly every time, and stays healthy long after.

🚀 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