StackPractices
beginner By Mathias Paulenko

Docker Health Check Configuration for Container Reliability

Add proper health checks to Docker containers with HEALTHCHECK

Topics: devops

Overview

The Docker HEALTHCHECK instruction tells Docker how to test whether a container is healthy. Without it, Docker only knows if a container is running, not if the application inside is actually serving requests. Health checks enable orchestrators to restart unhealthy containers automatically.

When to Use

  • You run containers in production and need automatic restart on failure
  • You use Docker Swarm or Kubernetes and need health status for scheduling
  • You want docker ps to show health status (healthy/unhealthy)
  • Your app can be running but not responding (deadlocks, memory exhaustion, stuck connections)

Solution

Basic HEALTHCHECK with curl

FROM node:20-alpine

WORKDIR /app

COPY . .
RUN npm ci --omit=dev

HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
    CMD curl -f http://localhost:3000/health || exit 1

EXPOSE 3000

CMD ["node", "server.js"]

HEALTHCHECK with wget (Alpine images without curl)

FROM node:20-alpine

WORKDIR /app
COPY . .
RUN npm ci --omit=dev

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

EXPOSE 3000
CMD ["node", "server.js"]

HEALTHCHECK with custom script

FROM python:3.12-slim

WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt

COPY healthcheck.sh /healthcheck.sh
RUN chmod +x /healthcheck.sh

HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=15s \
    CMD /healthcheck.sh

EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]
#!/bin/bash
# healthcheck.sh
set -e

# Check if the HTTP endpoint responds
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health)

if [ "$response" = "200" ]; then
    exit 0
else
    echo "Health check failed: HTTP $response"
    exit 1
fi

HEALTHCHECK for PostgreSQL

FROM postgres:16-alpine

HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
    CMD pg_isready -U postgres -d mydb || exit 1

HEALTHCHECK for Redis

FROM redis:7-alpine

HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
    CMD redis-cli ping | grep PONG || exit 1

Docker Compose healthcheck with depends_on

# docker-compose.yml
services:
    api:
        build: .
        ports:
            - "3000:3000"
        healthcheck:
            test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
            interval: 30s
            timeout: 5s
            retries: 3
            start_period: 10s

    worker:
        build: ./worker
        depends_on:
            api:
                condition: service_healthy
            redis:
                condition: service_healthy

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

Health endpoint in Node.js

const http = require("http");

const server = http.createServer((req, res) => {
    if (req.url === "/health" && req.method === "GET") {
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ status: "healthy" }));
        return;
    }
    // ... other routes
});

server.listen(3000);

Health endpoint in Python (FastAPI)

from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
async def health():
    return {"status": "healthy"}

Explanation

The HEALTHCHECK instruction takes four options:

  • --interval: Time between checks (default 30s). Use shorter intervals for critical services.
  • --timeout: Max time to wait for a response before marking as failed (default 30s).
  • --retries: Consecutive failures before marking unhealthy (default 3).
  • --start-period: Grace period before counting failures (default 0s). Gives the app time to boot.

The command must return exit code 0 (healthy) or 1 (unhealthy). Docker runs the command inside the container.

Health states:

  • starting: Initial state during start-period. Not counted as failures.
  • healthy: Last check returned exit 0.
  • unhealthy: retries consecutive checks failed.

In Docker Compose, depends_on with condition: service_healthy waits for a dependency to pass its health check before starting. This solves startup ordering problems.

Variants

Check MethodImage RequirementUse When
curl -fcurl installedStandard HTTP check
wget —spiderwget installedAlpine without curl
Custom scriptbash + toolsMulti-step checks
pg_isreadyPostgreSQL imageDatabase readiness
redis-cli pingRedis imageCache readiness
node -eNode.js imageJS-based check

Guidelines

  • Always add a HEALTHCHECK to production Dockerfiles.
  • Set --start-period to match your app’s startup time (10-30s for most apps).
  • Use --interval=30s for general services, 10s for critical infrastructure.
  • Keep --timeout short (3-10s). A slow health check is worse than no check.
  • Create a dedicated /health endpoint that returns 200 without side effects.
  • Check the actual application, not just the port. A TCP open check is not enough.
  • Use depends_on with condition: service_healthy in Compose for startup ordering.
  • For databases, use built-in tools (pg_isready, redis-cli ping) instead of HTTP.

Common Mistakes

  • Not setting --start-period. Slow-starting apps get marked unhealthy before they finish booting.
  • Using a health check that hits the database. This adds load and can cause cascading failures.
  • Setting --interval too low (1-5s). This wastes CPU and can overload the app with health requests.
  • Not creating a dedicated health endpoint. Checking / might return 200 even if the API is broken.
  • Forgetting to install curl or wget in the image. The health check silently fails.
  • Returning HTTP 200 for error pages. Some frameworks return 200 for all responses, breaking the check.
  • Not using depends_on with condition: service_healthy. Containers start before dependencies are ready.

Performance Tips

  1. Keep health check commands fast. Target under 1 second execution time:
# Fast: simple HTTP check
curl -sf -m 2 http://localhost:3000/health

# Slow: database query
psql -c "SELECT count(*) FROM large_table"  # Don't do this
  1. Use --start-period to avoid false negatives during boot. Set it to your app’s typical startup time plus 50 percent margin:
# App takes ~20s to boot, set start-period to 30s
HEALTHCHECK --start-period=30s --interval=30s --timeout=5s --retries=3 \
    CMD curl -f http://localhost:3000/health
  1. Avoid health checks that write to disk. Each check runs frequently and can fill disk with logs:
# Bad: writes to disk every 30s
HEALTHCHECK CMD curl -f http://localhost:3000/health >> /var/log/health.log

# Good: no disk writes
HEALTHCHECK CMD curl -sf http://localhost:3000/health

Frequently Asked Questions

What happens when a container is marked unhealthy?

Docker reports the status in docker ps. Docker Swarm automatically restarts unhealthy containers. In Kubernetes, health checks map to liveness/readiness probes and trigger pod restarts.

Should I use the same endpoint for liveness and readiness?

No. Liveness checks if the app is alive (should be cheap, no dependencies). Readiness checks if the app can serve requests (may check database connectivity). Use /health for liveness and /ready for readiness.

Can I disable a health check from the base image?

Yes. Use HEALTHCHECK NONE in your Dockerfile to disable inherited health checks.

How do I check container health from the CLI?
docker inspect --format='{{.State.Health.Status}}' <container-name>

This returns healthy, unhealthy, or starting.

Health Endpoint in Java (Spring Boot)
// src/main/java/com/example/HealthController.java
package com.example;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@RestController
public class HealthController {

    @GetMapping("/health")
    public String health() {
        return "{\"status\":\"healthy\"}";
    }
}

@Component
public class DatabaseHealthIndicator implements HealthIndicator {

    @Override
    public Health health() {
        // Check database connectivity
        try {
            // Perform a simple query
            return Health.up().withDetail("database", "reachable").build();
        } catch (Exception e) {
            return Health.down().withDetail("error", e.getMessage()).build();
        }
    }
}
# application.yml — Enable actuator health endpoints
management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: when_authorized
Multi-Step Health Check Script
#!/bin/bash
# healthcheck-advanced.sh — Multi-step health verification
set -e

FAIL=0

# Step 1: Check HTTP endpoint
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health 2>/dev/null || echo "000")
if [ "$HTTP_CODE" != "200" ]; then
    echo "FAIL: HTTP endpoint returned $HTTP_CODE"
    FAIL=1
fi

# Step 2: Check disk space
DISK_USAGE=$(df / | awk 'NR==2 {print int($5)}')
if [ "$DISK_USAGE" -gt 90 ]; then
    echo "FAIL: Disk usage at ${DISK_USAGE}%"
    FAIL=1
fi

# Step 3: Check if critical process is running
if ! pgrep -x "gunicorn" > /dev/null; then
    echo "FAIL: gunicorn process not found"
    FAIL=1
fi

# Step 4: Check memory usage
MEM_USAGE=$(free | awk '/Mem:/ {printf "%.0f", $3/$2 * 100}')
if [ "$MEM_USAGE" -gt 95 ]; then
    echo "FAIL: Memory usage at ${MEM_USAGE}%"
    FAIL=1
fi

if [ "$FAIL" -eq 0 ]; then
    exit 0
else
    exit 1
fi
Health Check with Logging
#!/bin/bash
# healthcheck-logged.sh — Health check with audit log
LOG_FILE="/var/log/healthcheck.log"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health 2>/dev/null || echo "000")

if [ "$HTTP_CODE" = "200" ]; then
    echo "$TIMESTAMP OK HTTP=$HTTP_CODE" >> "$LOG_FILE"
    exit 0
else
    echo "$TIMESTAMP FAIL HTTP=$HTTP_CODE" >> "$LOG_FILE"
    exit 1
fi
Docker Compose Override for Health Check Tuning
# docker-compose.prod.yml — Override health check for production
services:
  api:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 15s
      timeout: 3s
      retries: 5
      start_period: 30s

  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d mydb && psql -U postgres -d mydb -c 'SELECT 1'"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 60s
# Apply production health check overrides
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Health Check for Message Queue Worker
FROM node:20-alpine

WORKDIR /app
COPY . .
RUN npm ci --omit=dev

# Health check for a worker that processes queue messages
HEALTHCHECK --interval=60s --timeout=10s --retries=3 --start-period=30s \
    CMD node -e " \
        const fs = require('fs'); \
        const stats = fs.statSync('/tmp/worker.heartbeat'); \
        const age = (Date.now() - stats.mtimeMs) / 1000; \
        if (age > 120) { console.error('Heartbeat stale: ' + age + 's'); process.exit(1); } \
        console.log('Heartbeat OK: ' + age + 's'); \
    "

CMD ["node", "worker.js"]
// worker.js — Write heartbeat file periodically
const fs = require("fs");

setInterval(() => {
    fs.writeFileSync("/tmp/worker.heartbeat", new Date().toISOString());
}, 30000);