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.
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:
{
"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:
{
"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.
curl http://localhost:9090/metrics# 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.
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": {
"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:
readinessProbe:
httpGet:
path: /health/readiness
port: 9090
initialDelaySeconds: 2
periodSeconds: 5When 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:
livenessProbe:
httpGet:
path: /health/liveness
port: 9090
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3Startup probe
For applications with slow bootstrap (large frameworks, heavy autoloaders):
startupProbe:
httpGet:
path: /health/startup
port: 9090
initialDelaySeconds: 1
periodSeconds: 2
failureThreshold: 15Security
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 binds127.0.0.1, and an explicit127.0.0.1:9090keeps the port reachable only from within the container or host. - Set
INTERNAL_ALLOW_IPSwhen the listener must be reachable off-host. It is a CIDR/IP allow-list that returns403on/metrics,/config, and plugin paths to peers outside it, while health probes stay reachable. Loopback is not implicit, so include127.0.0.1/32if 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
containerPortbut 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.
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.
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:9090services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "80:80"
environment:
- DOCUMENT_ROOT=/var/www/html/public
- INTERNAL_ADDR=127.0.0.1:9090Troubleshooting
Health, metrics, and config endpoints not available
INTERNAL_ADDR is not set.
Fix: Add the environment variable:
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:
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
- Metrics: full Prometheus metrics reference
- Health Checks: detailed health check behavior
- Configuration Reference: all environment variables
- Graceful Shutdown: shutdown sequence and internal server lifecycle