Configuring Docker Compose Container Health Checks

Configuring Docker Compose Container Health Checks

What You’ll Need

  • Hetzner VPS or Contabo VPS for hosting your production container stack
  • DigitalOcean as an alternative cloud infrastructure provider
  • n8n Cloud or self-hosted n8n for orchestration and alert notifications
  • Namecheap if you need domain names for your public endpoints

Table of Contents


Understanding Docker Compose Health Checks

A container running PID 1 inside its isolated namespace is not proof that your application is functioning correctly. A Web server might suffer from a deadlock, an API process might hang on an unhandled promise rejection, or a database might exhaust its connection pool. In all these scenarios, Docker marks the container as running because the process has not terminated, yet the application is completely unavailable to users.

Docker health checks solve this problem by executing periodic commands inside the running container to determine actual service viability. When you define a health check in Docker Compose, the Docker daemon runs the specified command inside the container context at fixed intervals. Based on the exit code of that command, Docker updates the container status from starting to healthy or unhealthy.

Here are the four standard exit codes that Docker recognizes for health checks:

  • 0 (Success): The container is healthy and ready to serve traffic.
  • 1 (Unhealthy): The container is not operating correctly.
  • 2 (Reserved): Do not use this code, as it is reserved for historical Docker internal uses.

When managing infrastructure costs across self-hosted software stacks, maintaining high availability without over-provisioning hardware is critical. If you are analyzing backend server efficiency, check out our breakdown on Temporal vs n8n vs Zapier API Automation Costs to understand how workflow engines impact container resource allocations.

Understanding the lifecycle timing parameters is critical for writing robust health check definitions:

  1. test: The exact command executed inside the container.
  2. interval: The time delay between consecutive health check executions.
  3. timeout: The maximum allowed execution time for a single health check attempt before marking it as failed.
  4. retries: The number of consecutive failures required before Docker marks a container as unhealthy.
  5. start_period: A grace period at container boot during which failed health checks do not count toward the maximum retry limit.
  6. start_interval: A shorter interval used during the start_period phase to verify container readiness faster upon initial boot.

Deep Dive into YAML Configuration Directives

Let us construct a production-ready Docker Compose configuration. Deploying this architecture on a reliable server like a Hetzner VPS gives you complete control over CPU and memory allocations while verifying service status.

Below is a complete docker-compose.yml file containing an Nginx gateway, a Node.js API application, a PostgreSQL database, and a Redis cache. Every service includes explicit health checks configured for its specific runtime characteristics.

version: "3.8"

services:
  postgres:
    image: postgres:15-alpine
    container_name: production_postgres
    environment:
      POSTGRES_DB: app_db
      POSTGRES_USER: db_user
      POSTGRES_PASSWORD: SecureDatabasePassword123!
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U db_user -d app_db"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
      start_interval: 2s
    restart: always

  redis:
    image: redis:7-alpine
    container_name: production_redis
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 5s
    restart: always

  api:
    image: node:18-alpine
    container_name: production_api
    working_dir: /usr/src/app
    command: >
      sh -c "node -e '
        const http = require(\"http\");
        const server = http.createServer((req, res) => {
          if (req.url === \"/health\") {
            res.writeHead(200, { \"Content-Type\": \"application/json\" });
            res.end(JSON.stringify({ status: \"ok\", uptime: process.uptime() }));
          } else {
            res.writeHead(404);
            res.end();
          }
        });
        server.listen(3000, () => console.log(\"API listening on port 3000\"));
      '"      
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      DB_HOST: postgres
      REDIS_HOST: redis
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 10s
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: always

volumes:
  pgdata:

When building high-concurrency database backends, repeatedly creating new database connections for health checks can strain PostgreSQL process limits. You can mitigate this by reviewing our complete guide on Setting Up PostgreSQL Connection Pooling with PgBouncer to keep connection costs minimal during high frequency health checks.

Notice the difference in syntax for the test key across services:

  1. ["CMD", "redis-cli", "ping"]: This passes arguments directly to the host process execution layer without invoking a subshell.
  2. ["CMD-SHELL", "pg_isready -U db_user -d app_db"]: This executes the target command inside the default shell of the container (/bin/sh -c), allowing environment variable evaluation and string concatenation.

💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.


Advanced Health Checks Dependent Services and Auto Healing

One of the greatest features of Docker Compose health checks is service dependency synchronization using condition: service_healthy. Standard depends_on syntax only waits for a dependent container process to enter the running state. It does not wait for the application inside that container to accept network connections. By leveraging health check conditions, dependent containers defer initialization until upstream databases and caches are fully initialized.

However, native Docker Engine does not automatically restart a container merely because its status changes to unhealthy. To implement true automated recovery in standalone Docker Compose environments, we can run a dedicated auto-healing utility like Docker Autoheal directly alongside our application services.

If you expose your health check endpoints over public network interfaces or behind ingress proxies, ensure your endpoints do not become vector targets for denial of service attacks. Read our step-by-step walkthrough on Protecting Self Hosted Endpoints with Rate Limiting to safely restrict access to sensitive service health metrics.

Here is a complete, fully real-world multi-container stack incorporating web proxying, application backends, stateful databases, and auto-healing services running on a DigitalOcean droplet or Contabo VPS.

version: "3.8"

services:
  autoheal:
    image: willfarrell/autoheal:1.2.0
    container_name: service_autoheal
    environment:
      AUTOHEAL_CONTAINER_HEADER: "all"
      AUTOHEAL_INTERVAL: "5"
      AUTOHEAL_START_PERIOD: "10"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    restart: always

  database:
    image: postgres:15-alpine
    container_name: stack_database
    environment:
      POSTGRES_DB: app_production
      POSTGRES_USER: admin_user
      POSTGRES_PASSWORD: SuperSecretPassword999
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U admin_user -d app_production"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 15s
    restart: always

  cache:
    image: redis:7-alpine
    container_name: stack_cache
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s
    restart: always

  web_api:
    image: nginx:1.25-alpine
    container_name: stack_web_api
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "80:80"
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:80/health_check || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s
    depends_on:
      database:
        condition: service_healthy
      cache:
        condition: service_healthy
    labels:
      autoheal: "true"
    restart: always

Below is the corresponding explicit ./nginx.conf file required by the web_api service to validate server responsiveness without placing heavy load on upstream applications:

server {
    listen 80;
    server_name localhost;

    location /health_check {
        access_log off;
        add_header Content-Type text/plain;
        return 200 'healthy\n';
    }

    location / {
        proxy_pass http://database:5432;
    }
}

Monitoring and Debugging Unhealthy Containers

When a health check fails, Docker records the stdout, stderr, and exit code output of the execution script inside the container runtime state. You can inspect this log to troubleshoot why a container transitioned into an unhealthy state.

Run the following command to check the current health status of all managed containers in your Docker Compose directory:

docker compose ps

The output will display the exact status in the STATUS column:

NAME                IMAGE               COMMAND                  SERVICE             CREATED             STATUS                   PORTS
production_api      node:18-alpine      "docker-entrypoint.s…"   api                 5 minutes ago       healthy                  0.0.0.0:3000->3000/tcp
production_postgres postgres:15-alpine  "docker-entrypoint.s…"   postgres            5 minutes ago       healthy (starting)       0.0.0.0:5432->5432/tcp
production_redis    redis:7-alpine      "docker-entrypoint.s…"   redis               5 minutes ago       unhealthy                0.0.0.0:6379->6379/tcp

To view the raw health check logs and diagnostic output for an unhealthy container, execute docker inspect:

docker inspect --format='{{json .State.Health}}' production_redis

To render the JSON output in a clean format using jq, pipe the output as follows:

docker inspect --format='{{json .State.Health}}' production_redis | jq .

The command returns detailed diagnostic trace data formatted like this:

{
  "Status": "unhealthy",
  "FailingStreak": 4,
  "Log": [
    {
      "Start": "2026-03-30T10:15:30.123456789Z",
      "End": "2026-03-30T10:15:30.234567890Z",
      "ExitCode": 1,
      "Output": "Could not connect to Redis at 127.0.0.1:6379: Connection refused\n"
    }
  ]
}

For complex services requiring multi-step verification (such as validating both network availability and file system write access), move your health logic into a dedicated shell script inside the Docker container image.

Here is a complete, fully written shell script named healthcheck.sh:

#!/bin/sh
set -e

HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health)

if [ "$HTTP_STATUS" -ne 200 ]; then
  echo "HTTP health check failed with status code: $HTTP_STATUS"
  exit 1
fi

if [ ! -w /tmp ]; then
  echo "Temporary directory /tmp is not writable"
  exit 1
fi

echo "All health checks passed successfully"
exit 0

Inside your docker-compose.yml, mount or copy healthcheck.sh into the container, mark it as executable, and invoke it directly in your healthcheck key:

version: "3.8"

services:
  worker:
    image: alpine:3.18
    container_name: background_worker
    command: >
      sh -c "apk add --no-mode-check curl &&
             touch /tmp/worker.log &&
             while true; do sleep 3600; done"      
    volumes:
      - ./healthcheck.sh:/usr/local/bin/healthcheck.sh:ro
    healthcheck:
      test: ["CMD", "/bin/sh", "/usr/local/bin/healthcheck.sh"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 5s
    restart: always

Getting Started

Deploying fully managed container stacks with health checks ensures that your self-hosted services automatically recover from application deadlocks and transient system failures. To spin up high-performance environments for your automation and deployment pipelines, deploy your Docker Compose infrastructure on a reliable host such as a Hetzner VPS, Contabo VPS, or DigitalOcean droplet. If you configure automated webhooks or public monitoring dashboards, secure your domain records quickly through Namecheap and coordinate custom notifications using n8n Cloud.

Outsource Your Automation

Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-HUGO for priority. Or DM at chasebot.online.

Want to automate this yourself?

Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.

Want this engine running on your own VPS?

This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.

Get System 3
system online