Guide: implement health checks and readiness probes for PHP apps in Docker Compose

By · Published on · 9 min read

Square glass blocks with bluish reflections forming a geometric pattern
Modern neutral texture suitable as background for technical infrastructure content. Photo: Chris F / Pexels

This guide shows how to implement health checks and readiness probes for PHP applications running in Docker Compose. Learn the difference between liveness and readiness, how to expose minimal endpoints, configure docker-compose.yml, integrate with Nginx, and cover queue workers. Includes practical examples and production tuning tips.

Concepts: liveness, readiness and how this applies in Docker Compose

Before you configure anything, align on terms. Liveness checks whether the process is still alive. Readiness indicates whether the application can safely receive traffic. Kubernetes has separate checks; Docker Compose has a single healthcheck per service, but you can emulate readiness with endpoints and routing rules.

With well-defined checks you avoid broken deployments, intermittent boot errors, and restart loops. It also reduces incidents after migrations or when external dependencies fluctuate.

To visualize health states via a GUI, a tool like Portainer helps inspecting services.

  • Liveness: kills/restarts when the process is hung.
  • Readiness: holds traffic until dependencies are ready.
  • Startup: gives extra time before starting health evaluations.
Concept What it checks Action when failing In Docker Compose
Liveness Process is responsive Restart the container healthcheck + restart policy
Readiness Can receive traffic Remove from load balancer / hold traffic Endpoint /ready + reverse proxy; depends_on: service_healthy
Startup Finished initializing Wait before checking start_period in healthcheck; wait-for scripts

Minimal PHP endpoints: /health (liveness) and /ready (readiness)

Simple HTTP endpoints work well. /health returns 200 when the process is up. /ready returns 200 only if essential dependencies (for example, the database and cache) are reachable and in the expected state.

Keep checks fast. Avoid heavy queries. Use short timeouts. Do not expose sensitive details in the response. If possible, restrict these endpoints to the internal Compose network.

  • /health: no external dependency checks, fast response.
  • /ready: tests light DB, cache, queue connections.
  • Plain text responses and clear HTTP codes: 200 or 503.
<?php
// public/healthz.php
// Um único script que responde /health e /ready
header('Content-Type: text/plain');
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/health', PHP_URL_PATH);

if ($path === '/health') {
    echo 'OK';
    exit(0);
}

if ($path === '/ready') {
    $errors = [];

    // Banco (ex.: Postgres)
    try {
        $dsn = sprintf(
            'pgsql:host=%s;port=%s;dbname=%s',
            getenv('DB_HOST') ?: 'db',
            getenv('DB_PORT') ?: '5432',
            getenv('DB_NAME') ?: 'app'
        );
        $pdo = new PDO($dsn, getenv('DB_USER') ?: 'app', getenv('DB_PASS') ?: 'secret', [PDO::ATTR_TIMEOUT => 2]);
        $pdo->query('SELECT 1');
    } catch (Throwable $e) {
        $errors[] = 'db';
    }

    // Cache (ex.: Redis)
    try {
        if (class_exists('Redis')) {
            $r = new Redis();
            $r->connect(getenv('REDIS_HOST') ?: 'redis', (int)(getenv('REDIS_PORT') ?: 6379), 1.5);
            $r->ping();
        }
    } catch (Throwable $e) {
        $errors[] = 'redis';
    }

    if ($errors) {
        http_response_code(503);
        echo 'NOT_READY: ' . implode(',', $errors);
        exit(1);
    }
    echo 'READY';
    exit(0);
}

http_response_code(404);
echo 'not found';

docker-compose.yml with healthcheck, start_period and depends_on

In Docker Compose, define a healthcheck per service. Use start_period to give extra warm-up time before the first evaluation. Emulate readiness by connecting Nginx only when the app is healthy with depends_on: condition: service_healthy.

The example below uses: app (PHP-FPM), nginx, db and redis. The app exposes a healthcheck via CLI for readiness. Nginx performs liveness via HTTP. For smaller images, see the multistage builds guide.

  • Use retries to avoid false negatives.
  • Tune interval, timeout and start_period.
  • Set appropriate restart policies (e.g. unless-stopped).
version: "3.9"
services:
  app:
    image: php:8.2-fpm-alpine
    working_dir: /var/www/html
    volumes:
      - ./:/var/www/html
    environment:
      DB_HOST: db
      DB_PORT: "5432"
      DB_NAME: app
      DB_USER: app
      DB_PASS: secret
      REDIS_HOST: redis
      REDIS_PORT: "6379"
    healthcheck:
      test: ["CMD-SHELL", "php /var/www/html/health/ready-cli.php"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 25s
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports: ["8080:80"]
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./:/var/www/html:ro
    depends_on:
      app:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost/health || exit 1"]
      interval: 10s
      timeout: 3s
      retries: 3
    restart: unless-stopped

  db:
    image: postgres:15
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    restart: unless-stopped

  redis:
    image: redis:7
    restart: unless-stopped

Queue workers and cron: reliable healthchecks and heartbeats

Workers don't have an HTTP endpoint. Let the healthcheck run a CLI script that validates dependencies and a progress heartbeat. Every N seconds the worker updates a file or a Redis key. The healthcheck fails if the heartbeat stalls.

This distinguishes normal idle periods (no jobs) from a hung worker. In frameworks like Laravel you can include queue stats in the script (for example, queue length).

  • Update a heartbeat timestamp on each iteration.
  • In the healthcheck, fail if the heartbeat is stale.
  • Validate Redis/DB connections with short timeouts.
<?php
// health/worker_health.php
// 1) Worker deve atualizar este arquivo/chave com timestamps.
// 2) Este script roda no healthcheck do container do worker.
$now = time();
$maxAge = (int)(getenv('HEARTBEAT_MAX_AGE') ?: 60); // segundos
$hbFile = getenv('HEARTBEAT_FILE') ?: '/tmp/worker_heartbeat';

$errors = [];

try {
    // Ex.: validar Redis rapidamente
    if (class_exists('Redis')) {
        $r = new Redis();
        $r->connect(getenv('REDIS_HOST') ?: 'redis', (int)(getenv('REDIS_PORT') ?: 6379), 1.5);
        $r->ping();
    }
} catch (Throwable $e) {
    $errors[] = 'redis';
}

$age = is_file($hbFile) ? ($now - filemtime($hbFile)) : PHP_INT_MAX;
if ($age > $maxAge) {
    $errors[] = 'stale_heartbeat';
}

if ($errors) {
    fwrite(STDERR, 'UNHEALTHY: '.implode(',', $errors)."\n");
    exit(1);
}

echo "OK\n";

Tuning interval, timeout, retries and start_period

Poor values cause false negatives or slow alerts. Tune them according to your app's performance and environment.

Good starting practices, then refine with real data:

  • interval: 10s to 30s for production services.
  • timeout: 2s to 5s. If larger than typical latency, investigate.
  • retries: 3 to 5 to absorb transient glitches.
  • start_period: cover boot time + cache warm-up.
  • Differentiate /health (fast) from /ready (dependency checks).

Traffic gating with Nginx and FPM ping (readiness without an orchestrator)

Without Kubernetes you can still implement readiness: serve a static /health and route /ready to PHP. While /ready doesn't return 200, have Nginx respond 503 to avoid early traffic. Optionally enable PHP-FPM's ping and monitor it.

See the PHP-FPM documentation on ping and status: https://www.php.net/manual/en/install.fpm.configuration.php.

  • /health: static 200 response from Nginx.
  • /ready: PHP route that tests DB/Redis and returns 200/503.
  • Optional: FPM ping for internal liveness checks.
server {
    listen 80;
    server_name _;
    root /var/www/html/public;

    # Liveness simples e rápido
    location = /health { return 200 "OK"; add_header Content-Type text/plain; }

    # Readiness: só 200 quando app estiver realmente pronto
    location = /ready {
        fastcgi_pass app:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public/healthz.php;
        fastcgi_param REQUEST_URI /ready;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass app:9000;
    }
}

# Opcional (no php-fpm www.conf do container app):
# ping.path = /fpm-ping
# ping.response = pong
# pm.status_path = /fpm-status

Observability: log and monitor your checks

Logs and metrics help explain why a service became unhealthy. Centralize container logs and create dashboards with time series for healthchecks and /ready responses.

To quickly build a metrics stack in Compose, see the Prometheus & Grafana guide. For structured logs from PHP apps, see Fluentd + Elasticsearch.

If you use a dynamic proxy, the Traefik vs Nginx comparison can help decide where to apply readiness gates.

How to validate it worked and common troubleshooting

After bringing up the stack, validate manually and force failures to observe behavior. Also test the worker path.

Common issues are usually straightforward to fix once you know where to look.

  • Failure due to too high/low timeout: adjust timeout and retries.
  • /ready slow: simplify checks and add connection timeouts.
  • Endpoint exposed publicly: restrict via network or firewall.
  • Service names mismatched in Compose: align service hosts with names.
  • Nginx pointing to wrong script: review SCRIPT_FILENAME.
# Suba o stack
docker compose up -d

# Verifique estados de saúde (Compose v2)
docker compose ps

# Teste endpoints pelo Nginx
curl -i http://localhost:8080/health
curl -i http://localhost:8080/ready

# Force uma falha de readiness (pare o Redis)
docker compose stop redis
curl -i http://localhost:8080/ready  # deve retornar 503

Frequently asked questions

What is the practical difference between /health and /ready in Docker Compose?

/health checks that the process is alive and responds quickly without touching external dependencies. /ready verifies that the application can already receive traffic, like being able to connect to the database and cache. In Compose you use healthcheck for liveness and Nginx gates plus depends_on for readiness.

Do I need Kubernetes for real readiness?

No. In Compose you can emulate readiness with a /ready endpoint, have Nginx return 503 while not ready, and use depends_on: condition: service_healthy to order service startup. It's not as feature-rich as an orchestrator, but it covers most project needs.

Should I add authentication to health endpoints?

Generally, no. Health checks should be fast and simple. Prefer restricting access by network (only inside the cluster/Compose) or firewall. Avoid exposing them publicly. Never return sensitive details, only minimal codes and messages.

How to check database and Redis without making /ready slow?

Use lightweight operations with short timeouts, like SELECT 1 and PING, and avoid heavy queries. Make connections with 1–3 second timeouts and fail fast. If your environment has latency spikes, increase healthcheck retries to reduce false negatives.

How to monitor health checks over time?

Collect logs from the endpoints and create metrics on dashboards. A practical option is to run Prometheus and Grafana with Compose to track variations and failures. See the metrics guide for Compose at /en/prometheus-grafana-docker-compose-vps.

Can I use PHP-FPM ping instead of /health?

Yes. FPM ping is useful for liveness of the PHP-FPM process. Enable ping.path and expose a route in Nginx that forwards to FPM. For full readiness, keep a /ready that validates application dependencies.

Want us to review your health checks before the next deploy?

Share your docker-compose.yml and your PHP app architecture. We can evaluate endpoints, tuning and Nginx configuration to reduce production failures.

Discuss your setup

See also