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:

bash
INTERNAL_ADDR=127.0.0.1:9090

When INTERNAL_ADDR is not set, the internal server does not start and no health endpoints are available.

Note

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 503 stops the very requests whose completion is the other way out.
Note

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.

bash
# Quick check curl -s -o /dev/null -w '%{http_code}' http://localhost:9090/health/readiness

GET /health

Returns the full server health status as JSON. Use this for dashboards and monitoring systems, not for Kubernetes probes.

bash
curl http://localhost:9090/health

Healthy response (200 OK):

json
{ "active_connections": 7, "executor_healthy": true, "plugins": {}, "pool_stalled": false, "status": "ok", "total_requests": 48203, "uptime_secs": 3612 }

Degraded response (503 Service Unavailable):

json
{ "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.

bash
curl http://localhost:9090/metrics

GET /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.

bash
curl -s http://localhost:9090/config | jq .
json
{ "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 }
Note

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:

yaml
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.

compose.yaml
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: 5s

Docker 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.

prompt
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.
prompt
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

Found a mistake? Report it →