Health Checks
OxPHP runs an internal HTTP server on a separate port for health monitoring, metrics collection, and configuration inspection. It stays isolated from application traffic, so monitoring never competes with user requests.
Setup
Set INTERNAL_ADDR to enable the internal server:
INTERNAL_ADDR=127.0.0.1:9090When INTERNAL_ADDR is not set, the internal server does not start and no health endpoints are available.
A port-only INTERNAL_ADDR (:9090) binds 127.0.0.1; use an explicit 0.0.0.0:9090 to expose the internal server off-host. When it is reachable off-host, restrict access with INTERNAL_ALLOW_IPS, a CIDR/IP allow-list. Peers outside it get 403 from /metrics, /config, and plugin paths, while health probes stay reachable. Loopback is not implicit, so list 127.0.0.1/32 to keep localhost access. The server warns at startup if the listener is exposed with no allow-list set.
Kubernetes probes
OxPHP provides dedicated endpoints for each Kubernetes probe type. Each endpoint is also available under a short alias (/healthz, /readyz, /startupz).
| Endpoint | Alias | Checks | 200 | 503 |
|---|---|---|---|---|
/health/liveness |
/healthz |
None (alive if responding) | Always | Never |
/health/readiness |
/readyz |
Not shutting down, worker threads running, pool not wedged, no failed plugins | Ready | Not ready |
/health/startup |
/startupz |
Worker threads running | Ready | Not ready |
Liveness always returns 200 OK. If the process can respond to the HTTP request, it is alive. No executor or plugin checks are performed, so Kubernetes does not restart pods due to transient worker pool issues.
Readiness returns 503 Service Unavailable when:
- The server is shutting down (graceful shutdown in progress)
- Every PHP worker thread has ended (the pool has nothing left to hand a request to)
- The pool is wedged: requests are waiting for workers that are idle and getting nothing done. This is detected in worker mode only (see The wedged-pool signal)
- Any plugin reports a failure
During graceful shutdown, readiness immediately returns 503, causing Kubernetes to remove the pod from Service endpoints before the drain completes.
Startup returns 503 Service Unavailable while no worker thread is running. Use this probe to prevent premature liveness kills during slow initialization. It reports on the threads, which exist as soon as the pool is created. It does not report on the application's own bootstrap running inside them.
The wedged-pool signal
A pool can stop taking work off its queue while every thread it owns is alive: requests pile up, no worker is busy, and nothing queued is ever run. Each request is still answered once it has spent its own wait budget (a 529, or a 499 where the client stopped waiting first), so from outside it does not read as a hang. Requests are still answered, but none of them is run, and the queue fills anyway, because only a pickup releases an entry. Every other signal reads healthy through it: the threads are all there, the plugins are fine, static files are served normally. A pool still in that state a minute later does not usually recover on its own, which is why readiness reports it.
The server watches for a combination a serving pool does not sustain: work waiting (queued requests, or admission refusals climbing) while at least one worker is idle and the pool has finished nothing since the previous scan.
- Two consecutive one-second scans are enough for a log line.
- Readiness waits a further minute. A pool whose workers have all stopped at once (most often a pool of a single worker) shows exactly this shape while a replacement re-runs the application's bootstrap. The new worker is serving again long before the minute is out, and pulling the replica early would take away the traffic it needs to show that.
- Readiness recovers when a worker actually finishes a request (a lull is not recovery), or after a minute in which the pool has not looked wedged at all. The second exit needs no traffic: a wedged pool holds its queue, so it cannot meet that condition, while a pool wrongly read as wedged can. Without it the state would be permanent, because the
503stops the very requests whose completion is the other way out.
The signal needs the count of requests the workers get through, which only worker mode (WORKER_MODE_ENABLED=true) keeps, so it exists in worker mode alone. Elsewhere readiness answers on the other three conditions, and oxphp_pool_stalled is absent rather than exported as a constant 0.
What the probes do not report
Load. An overloaded server stays Ready. Once the admission queue is full and the wait budget is spent, requests are refused with 529 Site is overloaded, and a replica doing that is still answering, quickly, on a pool that works. Taking it out of rotation would move its traffic onto its neighbours, and under an even overload that removes every replica in turn until the Service has no endpoints left: a degraded site becomes an unreachable one, with the load still there. Watch oxphp_admission_refused_total{reason=...} instead and add capacity. Read oxphp_admission_wait_wasted_total alongside oxphp_admission_wait_budget_us, though: a wait_timeout refusal does not by itself imply a pool that is getting through its work, and where the pool frees no worker for the whole of a request's wait the remedy is the opposite one: shorten the wait budget rather than grow the pool. That counter only counts while the budget is at its ceiling. The server also says so in its log: a WARN on the scan that starts shedding, repeated about once a minute while it lasts, and an INFO once a whole minute has passed without a refusal. See Prometheus Metrics.
An exhausted connection budget. When MAX_CONNECTIONS permits run out, the accept loop parks and new clients get no answer at all, but the internal listener does not go through that budget, so the probes keep responding. This is also load rather than a fault, and does not change readiness; oxphp_accept_stalled reports it.
Slow responses. Nothing here measures latency. A pool that answers everything late is Ready.
All probe endpoints return Content-Type: text/plain with the probe name as the body (e.g., readiness). Kubernetes only inspects the HTTP status code.
# Quick check
curl -s -o /dev/null -w '%{http_code}' http://localhost:9090/health/readinessGET /health
Returns the full server health status as JSON. Use this for dashboards and monitoring systems, not for Kubernetes probes.
curl http://localhost:9090/healthHealthy response (200 OK):
{
"active_connections": 7,
"executor_healthy": true,
"plugins": {},
"pool_stalled": false,
"status": "ok",
"total_requests": 48203,
"uptime_secs": 3612
}Degraded response (503 Service Unavailable):
{
"active_connections": 7,
"executor_healthy": false,
"plugins": {},
"pool_stalled": false,
"status": "degraded",
"total_requests": 48203,
"uptime_secs": 3612
}| Field | Type | Description |
|---|---|---|
status |
string | "ok" when all subsystems are healthy, "degraded" otherwise. Load is not a subsystem fault: a server shedding 529 under overload reports "ok" |
uptime_secs |
integer | Seconds since the server started |
total_requests |
integer | Total HTTP requests processed on the main port |
active_connections |
integer | Currently open connections on the main port |
executor_healthy |
boolean | Whether any PHP worker thread is still running. false means the pool has no thread left to hand a request to |
pool_stalled |
boolean | Whether the pool is wedged: requests waiting for workers that are idle and getting nothing done. Always false outside worker mode, where the state is not detected (see The wedged-pool signal). true switches the HTTP status to 503 |
plugins |
object<string, string> |
Per-plugin health: keys are plugin names, values are "ok", "degraded", or "failed". Empty {} when no plugins report health. A "failed" plugin causes the HTTP status to switch to 503; "degraded" appears here but keeps the status at 200. |
GET /metrics
Returns Prometheus-compatible metrics in text exposition format. See Prometheus Metrics for the full metric reference.
curl http://localhost:9090/metricsGET /config
Returns the active server configuration as JSON. TLS certificate and key paths, internal_addr, and error_pages_dir are scrubbed from the response for security.
curl -s http://localhost:9090/config | jq .{
"access_log": "all",
"async_in_flight_cap": 0,
"async_max_fibers": 256,
"async_queue_capacity": 0,
"async_workers": 0,
"brotli_level": 5,
"deny_file": {"loaded": false},
"document_root": "/var/www/html/public",
"drain_timeout_seconds": 25,
"entry_file": "/var/www/html/public/index.php",
"executor_type": "sapi",
"gzip_level": 6,
"header_timeout_seconds": 5,
"listen_addr": "0.0.0.0:443",
"log_level": "info",
"max_connections": 10000,
"max_query_body": 524288,
"php_workers": "8",
"plugins": {},
"queue_capacity": 1024,
"queue_max_waiting": 1024,
"queue_max_waiting_bytes": 67108864,
"queue_wait_timeout_ms": 1000,
"rate_limit": 100,
"rate_window_seconds": 60,
"runtime_hooks": [],
"static_max_age": 2592000,
"static_revalidate": false,
"superglobals_enabled": true,
"tls_enabled": true,
"tls_min_version": "1.2",
"tokio_workers": 4,
"trace_context": false,
"trusted_proxies": false,
"worker_max_memory_mib": 0,
"worker_mode_enabled": false,
"zstd_level": 6
}TLS certificate and key paths are never emitted (tls_enabled indicates whether TLS is active), and internal_addr and error_pages_dir are scrubbed from the served response.
Kubernetes integration
Use dedicated probe endpoints for each probe type:
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: oxphp
image: ghcr.io/oxphp/oxphp:0.13.0
env:
- name: INTERNAL_ADDR
value: "0.0.0.0:9090"
- name: INTERNAL_ALLOW_IPS
# the Prometheus scraper's network, plus loopback
value: "10.0.0.0/8,127.0.0.1/32"
ports:
- containerPort: 80
- containerPort: 9090
startupProbe:
httpGet:
path: /health/startup
port: 9090
initialDelaySeconds: 1
periodSeconds: 2
failureThreshold: 15
livenessProbe:
httpGet:
path: /health/liveness
port: 9090
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/readiness
port: 9090
periodSeconds: 5
failureThreshold: 2| Probe | Effect on failure |
|---|---|
| Startup | Kubernetes waits and does not kill the pod during initialization |
| Liveness | Kubernetes restarts the pod |
| Readiness | Kubernetes removes the pod from Service endpoints (no restart) |
The short aliases (/healthz, /readyz, /startupz) are fully equivalent and can be used instead.
Docker Compose health check
This example binds the internal server to 127.0.0.1, while the Kubernetes one binds 0.0.0.0: Docker runs the healthcheck command inside the container's own network namespace, where loopback reaches the listener, but the kubelet sends its httpGet probes from outside the pod's network namespace, where it does not.
services:
oxphp:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "8080:80"
environment:
INTERNAL_ADDR: "127.0.0.1:9090"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9090/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5sDocker marks the container as unhealthy after the configured number of retries fail, which can trigger restart policies or load balancer removal.
Prompts for your agent
These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.
Set up OxPHP health checks for my project. Read the `operations/health-checks` page through the OxPHP documentation MCP server.
Add a healthcheck to my docker-compose.yml and liveness and readiness probes to my Kubernetes Deployment, based on the services and ports this repository already defines.My OxPHP container is being restarted by its orchestrator and I do not know why. Read the `operations/health-checks` page through the OxPHP documentation MCP server.
Check my health check configuration against what the internal server actually exposes, and tell me which of the two is wrong.See also
- Prometheus Metrics: full reference for all exposed metrics
- Graceful Shutdown: how health probes interact with shutdown draining
- Configuration Reference: all environment variables including
INTERNAL_ADDR