Healthcheck #

A healthcheck is the mechanism for determining whether a service inside a container is truly healthy — not just that its process is running, but that it’s ready to accept requests and respond correctly. Without a healthcheck, you can’t tell a running-but-broken container apart from a fully functional one.

This article covers healthcheck syntax in Compose, how to write effective test commands, and best practices for health monitoring.

What Is a Healthcheck? #

A healthcheck is a command run periodically inside the container by the Docker daemon. The exit code determines the status:

  • 0 — healthy
  • 1 — unhealthy
  • 2 — reserved (unused)

Healthcheck status is visualized in docker compose ps:

NAME          SERVICE   STATUS              PORTS
myapp-web-1   web       Up 5 minutes (healthy)   0.0.0.0:80->80/tcp
myapp-api-1   api       Up 5 minutes (healthy)   8080/tcp
myapp-db-1    db        Up 5 minutes (healthy)

If the healthcheck fails, the status shows (unhealthy). The container doesn’t necessarily restart — that depends on the restart policy.

Healthcheck Syntax #

The healthcheck field sits under a service.

services:
  api:
    image: myapi
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

Healthcheck fields #

FieldDefaultDescription
test(required)The test command
interval30sInterval between tests
timeout30sPer-test timeout
retries3Failures before unhealthy
start_period0sInitial grace period

Test Command Formats #

CMD exec form (no shell):

test: ["CMD", "curl", "-f", "http://localhost:8080/health"]

The CMD exec form doesn’t run a shell. Arguments are passed directly to the executable. Cleaner, but can’t use shell features (pipes, env var expansion, etc.).

CMD-SHELL form (with shell):

test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]

CMD-SHELL runs the command in a shell (/bin/sh -c). Shell features work. Add || exit 1 to guarantee exit code 1 on failure.

NONE (disable):

test: ["NONE"]

Disables an inherited healthcheck from the image.

CMD vs CMD-SHELL: Use CMD when the test binary accepts arguments directly. Use CMD-SHELL when you need shell logic (pipes, conditionals, env vars). For HTTP checks, CMD-SHELL is usually more flexible.

HTTP Services #

api:
  healthcheck:
    test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
    interval: 30s
    timeout: 10s
    retries: 3

The /health endpoint must return 200 when the service is ready. It can be more specific: /health/live (process running) vs /health/ready (ready to accept requests).

Postgres #

db:
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U postgres -d myapp"]
    interval: 10s
    timeout: 5s
    retries: 5

pg_isready is the official Postgres tool for checking whether the server accepts connections. Returns 0 when ready, 3 when not ready.

MySQL #

db:
  healthcheck:
    test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$MYSQL_ROOT_PASSWORD"]
    interval: 10s
    timeout: 5s
    retries: 5

Redis #

cache:
  healthcheck:
    test: ["CMD", "redis-cli", "ping"]
    interval: 10s
    timeout: 3s
    retries: 3

redis-cli ping returns “PONG” (interpreted as exit 0).

MongoDB #

db:
  healthcheck:
    test: ["CMD", "mongo", "--eval", "db.runCommand({ping: 1})"]
    interval: 10s
    timeout: 5s
    retries: 5

RabbitMQ #

mq:
  healthcheck:
    test: ["CMD", "rabbitmq-diagnostics", "ping"]
    interval: 30s
    timeout: 10s
    retries: 3
    start_period: 60s

start_period of 60s gives RabbitMQ time to boot.

Nginx #

nginx:
  healthcheck:
    test: ["CMD", "wget", "--spider", "-q", "http://localhost/health"]
    interval: 30s
    timeout: 10s
    retries: 3

Custom Health Endpoints #

Applications you write should expose a /health endpoint returning 200 when healthy. Pattern:

# Flask
@app.route('/health')
def health():
    # Check dependencies
    try:
        db.session.execute('SELECT 1')
        return '', 200
    except Exception:
        return 'Database unavailable', 503
// Express
app.get('/health', (req, res) => {
  // Check dependencies
  db.ping()
    .then(() => res.status(200).send('OK'))
    .catch(() => res.status(503).send('DB down'));
});

Start Periods for Slow-Starting Services #

Some services take a long time to be fully ready. start_period provides a grace period during which failed healthchecks are ignored.

db:
  image: postgres:16-alpine
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U postgres"]
    interval: 10s
    timeout: 5s
    retries: 5
    start_period: 60s  # 60s grace period

During the first 60 seconds, failed healthchecks don’t count as unhealthy. After 60s, normal healthcheck behavior.

Important: start_period doesn’t delay the first healthcheck — it only changes when the “unhealthy” counter starts counting. Healthchecks still run and may fail early on.

Interval and Retries Tuning #

The defaults (interval 30s, retries 3) may not be optimal for every service.

Services needing quick detection:

healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
  interval: 5s
  timeout: 2s
  retries: 2

Detection within 5-10s when the service goes down. Fits services that must restart immediately on failure.

Services needing to ignore transient failures:

healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
  interval: 30s
  timeout: 10s
  retries: 5
  start_period: 60s

Detection within 2-3 minutes, ignoring short failures. Fits services with occasional transient blips.

Integration with depends_on #

Healthchecks become powerful when combined with condition: service_healthy in depends_on.

services:
  api:
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
  
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
  
  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

api won’t start until both db and cache are healthy.

Restart Policies with Healthchecks #

When a container becomes unhealthy, the restart policy determines what happens.

services:
  api:
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
    restart: on-failure  # or unless-stopped

restart: on-failure restarts the container on non-zero exit codes. A failed healthcheck produces exit code 1 (via || exit 1), so the container restarts.

restart: unless-stopped always restarts unless manually stopped. More aggressive — “unhealthy” containers also get restarted.

Anti-Patterns: Useless Healthchecks #

Healthchecks That Always Pass #

# BAD
healthcheck:
  test: ["CMD", "true"]  # always returns 0

Healthchecks must genuinely test the service’s condition.

Healthchecks That Only Check the Process #

# BAD
healthcheck:
  test: ["CMD-SHELL", "kill -0 1"]  # checks whether PID 1 exists

A running process ≠ a ready service. A web server can run but be overloaded; a database can run but be locked.

Healthchecks That Are Too Complex #

# BAD
healthcheck:
  test: ["CMD-SHELL", "complex multi-step check involving many services"]

Healthchecks must be lightweight and fast. If they take long, they’ll time out and always fail.

Test Commands Not in the Image #

# BAD — `curl` doesn't exist in alpine images
healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost"]

Make sure the command exists in the image, or install it in the Dockerfile.


Liveness vs Readiness Probes #

A single Docker healthcheck differs from Kubernetes, which has separate liveness and readiness probes. But the concept can be adopted with multiple containers per service.

Liveness — is the process still alive? Restart if not. Readiness — is the service ready to accept traffic? Don’t send traffic if not ready.

In Docker Compose, you can simulate this with:

services:
  app-liveness:
    image: myapp
    healthcheck:
      test: ["CMD-SHELL", "kill -0 1 || exit 1"]  # process running
      interval: 30s
      retries: 3
    restart: on-failure

  app-readiness:
    image: myapp
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
      interval: 5s
      retries: 2

But this isn’t a clean pattern. For proper liveness + readiness, use Kubernetes or a platform supporting both probes.

Healthcheck Output Logging #

When a healthcheck fails, exit code 1 is logged. For debugging, add output to the test command.

healthcheck:
  test: ["CMD-SHELL", "curl -v http://localhost:8080/health 2>&1 || exit 1"]
  interval: 30s
  timeout: 10s
  retries: 3

Verbose output can be seen in docker inspect <container> or docker compose logs <service>.

Custom Healthcheck Scripts #

For complex tests, write a separate script and mount it into the container.

# healthcheck.sh
#!/bin/sh
set -e

# Check the process
kill -0 1 || exit 1

# Check the database connection
pg_isready -h db -U app || exit 1

# Check the Redis connection
redis-cli -h cache ping > /dev/null || exit 1

# Check an external API (if critical)
curl -f https://api.external.com/health > /dev/null 2>&1 || exit 1

exit 0

Mount it into the container and reference it in the healthcheck:

services:
  app:
    volumes:
      - ./healthcheck.sh:/healthcheck.sh:ro
    healthcheck:
      test: ["CMD-SHELL", "sh /healthcheck.sh"]
      interval: 30s
      timeout: 15s
      retries: 3
Custom script trade-offs: More powerful, can test multiple aspects. But more complex and can fail due to external factors (e.g. external APIs). For production, consider whether external dependencies should be part of the healthcheck or not.

Healthchecks and Observability #

Healthcheck status can be exported to monitoring systems.

Built-in Docker metrics:

# Healthcheck status
docker inspect --format='{{.State.Health.Status}}' <container>

# Last output
docker inspect --format='{{range .State.Health.Log}}{{.Output}}{{end}}' <container>

Prometheus + cAdvisor:

cAdvisor automatically exports the container_health metric to Prometheus. Alerts can be set for unhealthy states.

External monitoring:

For production services, external monitoring (Pingdom, Datadog, etc.) runs healthchecks from outside, so it can detect problems invisible from inside the container (network issues, host down, etc.).

Per-Container vs External Healthchecks #

AspectPer-container (Docker)External monitoring
LocationFrom inside the containerFrom outside the host
DetectsBroken internal servicesNetwork, host, or service issues
CostBuilt-in, freeExtra tooling, possibly paid
ReliabilityDepends on the health endpointIndependent of the service

For serious production, use both:

  • Per-container healthchecks for depends_on and restart policies.
  • External monitoring for alerting the team.

Recap Cheatsheet #

PatternExample
HTTP healthcheck["CMD-SHELL", "curl -f http://localhost/health || exit 1"]
Postgres["CMD-SHELL", "pg_isready -U postgres"]
Redis["CMD", "redis-cli", "ping"]
RabbitMQ["CMD", "rabbitmq-diagnostics", "ping"]
Generic exec["CMD", "binary", "arg"]
Disable inherited["NONE"]
Slow startupstart_period: 60s
Quick detectioninterval: 5s, retries: 2
Tolerate transientsinterval: 30s, retries: 5

A Complete Example: Production-Ready Healthchecks #

services:
  nginx:
    image: nginx:alpine
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://localhost/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    restart: unless-stopped

  api:
    build: ./api
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 30s
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    volumes:
      - db-data:/var/lib/postgresql/data
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    restart: unless-stopped

volumes:
  db-data:

This stack has healthchecks on every service with appropriate timing. db has more retries (5) because Postgres sometimes needs longer to recover. api has a 30s start period for warmup. nginx only needs a 10s start period because it’s fast.


Summary #

  • Healthchecks determine whether a service is ready, not just whether its process is running. The default docker ps can’t tell the difference.
  • Syntax: test: ["CMD-SHELL", "command"] is the most flexible. CMD exec form for direct binaries.
  • Fields: test (required), interval, timeout, retries, start_period. Defaults fit most cases; tune as needed.
  • Integration with depends_on: use condition: service_healthy so dependents start after the dependency is truly ready.
  • Restart policies: on-failure or unless-stopped for auto-restart when unhealthy.
  • Anti-patterns: healthchecks that always pass, process-only checks, overly complex checks, or commands missing from the image.
  • Best practices: a simple health endpoint in the application (return 200 when healthy), official tools for databases (pg_isready, redis-cli ping), tuned intervals/retries per service.
  • Distinguish liveness vs readiness: liveness = process running (restart if down), readiness = ready for traffic (don’t route traffic).
  • Healthcheck + load balancer = automatic removal from rotation when unhealthy (zero manual intervention).
  • Monitor healthcheck failures via centralized logging/alerting, don’t rely on visual checks.

← Previous: Depends On & Startup Order   Next: Local Development →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact