Internal Server

OxPHP runs a separate HTTP server on a dedicated port for health checks, Prometheus metrics, and live configuration inspection. It is isolated from the main application listener and has none of its TLS, rate limiting, request IDs, or event processing.

How it works

Set INTERNAL_ADDR to a listen address (e.g. 127.0.0.1:9090) and OxPHP starts a second HTTP listener on that address. The internal server exposes the built-in endpoints below:

  • /health: aggregate JSON status
  • /health/liveness, /healthz (alias): liveness probe
  • /health/readiness, /readyz (alias): readiness probe
  • /health/startup, /startupz (alias): startup probe
  • /metrics: Prometheus exposition
  • /config: active server configuration JSON

Plugins can register additional endpoints under the /__<plugin>/ prefix. During graceful shutdown, the internal server stays available until the main server finishes draining connections.

Note

The internal server only starts when INTERNAL_ADDR is explicitly set. Without it, health, metrics, and config endpoints are not available over HTTP.

Configuration

Variable Default Description
INTERNAL_ADDR (unset) Address for the internal server. Not started when unset. A port-only value (:9090) binds 127.0.0.1; use an explicit 0.0.0.0:9090 to expose it off-host. Example: 127.0.0.1:9090
INTERNAL_ALLOW_IPS (unset) Comma-separated CIDR/IP allow-list. Peers outside the list receive 403 on /metrics, /config, and /__<plugin>/ paths; health probes always stay reachable. Unset/empty = allow all. Loopback is not implicit, so list 127.0.0.1/32 to keep localhost access. A malformed list aborts startup

Endpoints

GET /health

Returns a JSON health status for dashboards and container health checks. For Kubernetes, use the dedicated probe endpoints instead (see Kubernetes integration): /health reports every subsystem at once, so wired to liveness, a failure in any of them would restart the pod.

200 OK when all systems are healthy:

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

503 Service Unavailable when a plugin has reported failure:

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

The plugins object lists every loaded plugin with its health status: "ok", "degraded", or "failed". The endpoint returns 503 when any plugin reports "failed", when no PHP worker thread is left running (executor_healthy: false), or when the pool is wedged (pool_stalled: true, worker mode only; see Health Checks). "degraded" plugins appear in the JSON body but the HTTP status remains 200. Load is not a fault either: a server shedding 529 under overload still reports "ok".

GET /metrics

Returns Prometheus-compatible metrics in text format (text/plain; version=0.0.4; charset=utf-8). Always returns 200.

bash
curl http://localhost:9090/metrics
text
# HELP oxphp_requests_total Total HTTP requests # TYPE oxphp_requests_total counter oxphp_requests_total 48203 # HELP oxphp_active_connections Current open connections # TYPE oxphp_active_connections gauge oxphp_active_connections 7 ...

For the full list of available metrics, see Metrics.

GET /config

Returns the active server configuration as JSON. Always returns 200.

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": { "allow": 1, "deny": 3, "entry": 1, "fallback": "404", "loaded": true, "rules": 5 }, "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": ["sleep", "streams", "net"], "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 }

runtime_hooks lists the runtime-hook categories the process installed at startup. It is a parsed set rather than the raw RUNTIME_HOOKS value, so all, 1 and sleep, streams, net all read as ["sleep", "streams", "net"] and a scraper does not need to reimplement that grammar. An empty list means no hook is installed, which is also what a misspelled variable name produces. It reports what was installed, not what is in effect: where there are no fibers at all the hooks delegate to the builtins they replaced, so a traditional-mode server lists the categories it set up while behaving natively. See Runtime hooks.

deny_file summarizes the .oxphpdeny file read from DOCUMENT_ROOT at startup: the number of rules split by action (deny, entry for > rules, allow for ! rules) and what a deny rule answers with, either the PHP_DENY_FALLBACK status code or "script" (without the script's path). The rules themselves are left out: on an internal server reachable from outside, they would be a map of exactly the paths the file protects. To see how each rule was read, start the server with LOG_LEVEL=debug and look for the oxphpdeny rule lines. Without the file, the entry is {"loaded": false}.

TLS certificate and key file paths are never emitted (only the tls_enabled boolean is exposed), and internal_addr and error_pages_dir are scrubbed from the served response. These are deployment topology and filesystem paths, which help an attacker and which scrapers do not need.

Plugin endpoints

Plugins can register custom endpoints under the /__<plugin_name>/ prefix. For example, a plugin named otel could expose /__otel/status. These endpoints are only available if the corresponding plugin is loaded and registered a handler.

Any path not matching a built-in or plugin endpoint returns 404 Not Found.

Kubernetes integration

Wire each probe to the endpoint made for it rather than pointing all three at /health. That endpoint folds every subsystem into one status, so on liveness it would answer a plugin failure or a wedged pool with a pod restart.

Readiness probe

/health/readiness controls whether Kubernetes routes traffic to the pod:

yaml
readinessProbe: httpGet: path: /health/readiness port: 9090 initialDelaySeconds: 2 periodSeconds: 5

When it returns 503, Kubernetes removes the pod from the Service endpoint list; traffic resumes when it returns 200 again. It reports faults rather than load, so an overloaded pod stays in rotation. See Health Checks.

Liveness probe

/health/liveness answers 200 whenever the process can serve an HTTP request at all. That is what liveness should mean, because a restart cannot fix an overloaded pool and it takes the in-flight work with it:

yaml
livenessProbe: httpGet: path: /health/liveness port: 9090 initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3

Startup probe

For applications with slow bootstrap (large frameworks, heavy autoloaders):

yaml
startupProbe: httpGet: path: /health/startup port: 9090 initialDelaySeconds: 1 periodSeconds: 2 failureThreshold: 15

Security

The internal server has no bearer-token authentication; access is controlled by where it binds and by INTERNAL_ALLOW_IPS. To protect it:

  • Bind the server to localhost. A port-only INTERNAL_ADDR (:9090) already binds 127.0.0.1, and an explicit 127.0.0.1:9090 keeps the port reachable only from within the container or host.
  • Set INTERNAL_ALLOW_IPS when the listener must be reachable off-host. It is a CIDR/IP allow-list that returns 403 on /metrics, /config, and plugin paths to peers outside it, while health probes stay reachable. Loopback is not implicit, so include 127.0.0.1/32 if you still need localhost access. The server warns at startup if the listener is exposed off-host with no allow-list set.
  • Do not expose the port as a Kubernetes Service. Declare it as a containerPort but do not create a Service for it; Kubernetes probes access container ports directly.
  • Use network policies to restrict access at the network layer if the port must be exposed.
Warning

The /config endpoint reveals operational details (document root, rate limits, worker counts, timeout values). TLS paths, internal_addr, and error_pages_dir are scrubbed, but consider whether the rest should be accessible from outside the pod.

Docker example

For a quick look you can publish the internal port; for production, bind the internal server to localhost, or under Kubernetes, whose httpGet probes do not reach a loopback listener, bind 0.0.0.0:9090 and set INTERNAL_ALLOW_IPS.

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.13.0 ports: - "80:80" - "9090:9090" environment: - DOCUMENT_ROOT=/var/www/html/public - INTERNAL_ADDR=0.0.0.0:9090

Troubleshooting

Health, metrics, and config endpoints not available

INTERNAL_ADDR is not set.

Fix: Add the environment variable:

bash
INTERNAL_ADDR=0.0.0.0:9090
/health returns 503 but the application works

A loaded plugin is reporting PluginHealth::Failed, or, less often, executor_healthy is false or pool_stalled is true. Check the plugins object in the health response to identify which plugin is failing:

bash
curl -s http://localhost:9090/health | jq '.plugins'
Cannot reach the internal server from outside the container

The server is bound to 127.0.0.1, which is only accessible from inside the container.

Fix: Change to 0.0.0.0 for external access and set INTERNAL_ALLOW_IPS. Kubernetes httpGet probes need the same change, because they do not reach a loopback listener.

Metrics not showing worker mode or async metrics

Worker mode metrics appear only when worker mode is enabled (WORKER_MODE_ENABLED=true). Async metrics appear only when ASYNC_WORKERS > 0 and at least one task has been dispatched or rejected.

See also

Found a mistake? Report it →