Guide: implement health checks and readiness probes for PHP apps in Docker Compose
By Kevin Marcondes · Published on · 9 min read
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 setupSee also
- Practical guide: active‑passive failover between two VPS with Keepalived, HAProxy & lsyncd for synchronized uploads
- Guide: technical checklist to prepare a Debian VPS with Docker Compose for PHP apps in production
- Practical guide to VPS sizing and PHP‑FPM tuning for Magento/OpenMage stores with seasonal peaks: from sizing to load testing
- Practical guide: rebuild PostgreSQL indexes in production using CREATE INDEX CONCURRENTLY and partial indexes (safe step‑by‑step)