GUIDE, WITHOUT THE GUESSWORK

Docker health checks: sane defaults for every self-hosted service

Every new container gets the same ritual: write a health check, wire up an alert, test it, forget about it. Here are copy-paste health checks for the services you actually run.

Docker health checks: sane defaults for every self-hosted service

You deploy Postgres. You deploy Redis. You deploy n8n, Plausible, maybe Uptime Kuma to monitor the rest. Each one needs a health check. Each health check is slightly different. Each one you write from scratch because the last one was three months ago and you don't remember the flags.

This is the monitoring tax on self-hosted infrastructure. It's not hard. It's just tedious enough that most people skip it and rely on "I'll notice when it's down."

Here are ready-to-paste Docker health checks for the services that show up in every self-hosted stack.

How Docker health checks work

Docker has a built-in HEALTHCHECK instruction. It runs a command inside the container on a schedule. If the command exits 0, the container is healthy. If it exits non-zero three times in a row (by default), the container is marked unhealthy.

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

Or in docker-compose.yml:

services:
  myapp:
    image: myapp:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

The four knobs:

The checks

PostgreSQL

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 15s

pg_isready is built into the Postgres image. It checks whether the server is accepting connections. No need to install curl or write a query.

For a deeper check that verifies the database is actually responding to queries:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres && psql -U postgres -c 'SELECT 1' > /dev/null 2>&1"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 20s

Redis

healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 5s

Redis responds to PING with PONG. If it doesn't, something is wrong. Redis starts fast, so the start period can be short.

If you use Redis with authentication:

healthcheck:
  test: ["CMD-SHELL", "redis-cli -a $REDIS_PASSWORD ping | grep PONG"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 5s

Nginx / reverse proxy

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

If your nginx doesn't have curl installed (the alpine image doesn't), use wget:

healthcheck:
  test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost/ || exit 1"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 5s

Node.js apps (Next.js, Express, n8n)

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

Node apps take longer to start, especially Next.js with its build cache. Set start_period to at least 30 seconds. If your app doesn't have a /api/health endpoint, check the root:

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

The catch: checking / on a Next.js app triggers a full page render. For a lighter check, add a dedicated health route that returns a 200 with no rendering.

MinIO

healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live || exit 1"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 15s

MinIO has a built-in health endpoint. Use /minio/health/live for liveness, /minio/health/cluster if you're running distributed mode.

MariaDB / MySQL

healthcheck:
  test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$MYSQL_ROOT_PASSWORD"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 20s

mysqladmin ping checks whether the server is alive. For a deeper check:

test: ["CMD-SHELL", "mysql -u root -p$MYSQL_ROOT_PASSWORD -e 'SELECT 1' > /dev/null 2>&1"]

MongoDB

healthcheck:
  test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1}).ok' --quiet | grep 1"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 20s

Older images use mongo instead of mongosh. Check your image version.

Traefik

healthcheck:
  test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8080/ping || exit 1"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 10s

Requires the ping endpoint enabled in Traefik's config:

# traefik.yml
ping:
  entryPoint: traefik

Wiring health checks to alerts

Docker health checks mark containers as unhealthy, but they don't send you a notification. You need something watching the health status.

Option 1: Docker events + a script

docker events --filter event=health_status | while read event; do
  echo "$event" | grep unhealthy && \
    curl -X POST "https://your-webhook.com" -d "Container unhealthy: $event"
done

Crude, but works for a single-server setup.

Option 2: Uptime Kuma with Docker socket

Uptime Kuma can monitor Docker containers directly via the Docker socket. Add a "Docker Host" monitor type, point it at your container, and it alerts on unhealthy status.

Option 3: Autoheal

The willfarrell/autoheal container watches for unhealthy containers and restarts them automatically:

services:
  autoheal:
    image: willfarrell/autoheal
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      AUTOHEAL_CONTAINER_LABEL: all

This restarts any unhealthy container. For selective healing, set AUTOHEAL_CONTAINER_LABEL=autoheal and add labels: ["autoheal=true"] to the containers you want auto-restarted.

Common mistakes

Using curl in images that don't have it. Alpine-based images often ship without curl. Use wget --spider or install curl in your Dockerfile. Don't skip the health check because the tool isn't there.

Setting timeout too low. A 1-second timeout on a database that's under load will false-alarm constantly. 5 seconds is the floor for most services, 10 for databases under write pressure.

Forgetting start_period. Without it, Docker starts checking immediately. Your service hasn't finished booting, fails three checks, and gets marked unhealthy before it ever had a chance.

Checking the wrong port. Your app listens on 3000 inside the container, but you mapped it to 8080 on the host. Health checks run inside the container — use the internal port.

Over-checking. A 5-second interval on a service that takes 2 seconds to respond means you're spending 40% of your health check budget on checking health. 30 seconds is fine for almost everything. Critical services can go to 10 seconds.

The template

For any new service you deploy, start with this and adjust:

healthcheck:
  test: ["CMD-SHELL", "<service-specific check> || exit 1"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 15s

Replace the test command. Adjust start_period based on how long the service takes to boot. Leave everything else alone unless you have a reason to change it.

The goal isn't perfect monitoring. It's having any monitoring at all. A 30-second health check that restarts a crashed container is infinitely better than finding out your database has been down since Tuesday.