TL;DR
A security-hardening and observability release. The headline is that the official image no longer serves as root out of the box — plus fail-closed configuration validation, encrypted trace export, and full exception data in APM spans.
- Drops to
www-databy default —oxphp serve/oxphp runstarted as root now bind their listeners as root and then permanently drop towww-databefore serving any traffic or running any PHP, so the official image no longer serves as root. Best-effort and never aborts startup;--user=rootrestores the old behaviour. → Upgrade from 0.9.0 - Fail-closed config validation — a half-configured TLS pair, a non-UTF-8
TLS_CERT/TLS_KEY, a malformedASYNC_*, an invalidSUPERGLOBALS_ENABLEDonoxphp run, and a foreign-syntaxTLS_MIN_VERSIONnow abort startup instead of silently falling back to defaults. → Upgrade from 0.9.0 - OTLP trace export over TLS — an
https://OTLP endpoint is now exported encrypted on both the gRPC and HTTP transports, verified against the system trust store (rustls, OpenSSL-free). → Highlights - Full exception data on APM spans — the
exceptionspan event now carriesexception.messageandexception.stacktracealongsideexception.type, so error inboxes (New Relic Errors Inbox, Jaeger, Grafana Tempo) render the message and stack without an in-application subscriber. → Highlights - Graceful drain on shutdown — SIGTERM stops accepting, sends
GOAWAYto HTTP/2 clients, and ends open long-lived streams promptly and uncatchably; ordinary in-flight requests get the fullDRAIN_TIMEOUT_SECONDSwindow (default lowered 30 → 25 to fit Kubernetes' grace period). → Highlights TLS_MIN_VERSIONfloor (1.2/1.3) and a Buggregator profiler export envelope round out the release.
Upgrading from v0.9.0? See the Upgrade checklist — the default privilege drop and fail-closed validation are the items that can change behaviour.
Highlights
Drop to www-data by default
oxphp serve and oxphp run now drop OS privileges to www-data by default. Started as root on a host where the www-data account exists (as in the official image), the process binds its listeners as root and then permanently drops to www-data before serving any traffic or running any PHP — so the official image no longer serves as root out of the box.
- The default is best-effort and never aborts startup: started non-root it keeps the current user; started as root without a
www-dataaccount it logs a warning and stays root. - To restore the previous always-root behaviour, pass
--user=root; to drop to another account, pass--user=<name|uid[:gid]>. An explicit--userremains fail-fast (it must start as root). - Orchestrator-level drops (
docker run --user, Composeuser:, KubernetessecurityContext) are unaffected and take precedence — when the container already starts non-root, the self-drop is a no-op.
Fail-closed TLS and config validation, plus TLS_MIN_VERSION
Several previously-silent misconfigurations are now hard errors at oxphp serve / oxphp run startup and at oxphp config --check:
- a half-configured TLS pair (
TLS_CERTset withoutTLS_KEY, or vice versa) — silently serving plain HTTP on a port meant for HTTPS fails open; - a non-UTF-8
TLS_CERT/TLS_KEYvalue (an exactly-empty value is treated as unset); - a malformed
ASYNC_WORKERS/ASYNC_QUEUE_CAPACITY/ASYNC_MAX_FIBERS(ASYNC_WORKERS=8xpreviously collapsed to0and disabled the async pool); - an invalid
SUPERGLOBALS_ENABLEDpassed tooxphp run(which previously fell back to defaults wholesale, silently re-enabling superglobals an operator had disabled).
New TLS_MIN_VERSION sets the minimum accepted protocol version — 1.2 (default) or 1.3 (a TLS 1.2 ClientHello is rejected at the handshake). It is validated at startup even when TLS is disabled, so a mistyped security floor cannot quietly weaken the configuration; the effective floor is reported as tls_min_version in /config. TLS startup errors now name the variable and the file (TLS_CERT: cannot read /etc/ssl/cert.pem: No such file or directory).
→ TLS docs
OTLP trace export over TLS
An OTEL_EXPORTER_OTLP_ENDPOINT with an https:// scheme is now exported encrypted on both the gRPC (grpc) and HTTP (http/protobuf) transports, with the collector certificate verified against the system trust store. Previously the default gRPC transport had no TLS support at all — an https:// gRPC endpoint failed to connect — and the HTTP transport's TLS backend was non-deterministic.
Both transports now use rustls with system (native) roots, keeping the build OpenSSL-free. Verification uses the OS trust store, so the runtime image must ship a CA bundle — the official image installs ca-certificates. Custom CA bundles and mutual-TLS client certificates are not yet supported.
Full exception data on APM span events
The exception span event now carries exception.message and exception.stacktrace alongside exception.type, matching the OpenTelemetry exception semantic conventions — so error inboxes (New Relic Errors Inbox, Jaeger, Grafana Tempo) render the message and stack without an in-application subscriber.
- Applies to both the automatic
#[OxPHP\Apm\Trace]decorator and the manualoxphp_apm_error($e)SDK call — the latter previously flipped the span to error status without recording any of it. A bare string argument (oxphp_apm_error('gateway timeout')) is recorded asexception.message. - Message and stacktrace are captured length-delimited and decoded lossily, so a non-UTF-8 or embedded-NUL message is preserved rather than dropped. The stacktrace comes from
getTraceAsString(). - Both are size-capped so a large payload can't bloat the export: the message by
OTEL_APM_MESSAGE_MAX_BYTES(default 4096) and the stacktrace byOTEL_APM_STACKTRACE_MAX_BYTES(default 8192);0disables either cap. Over the cap each is truncated from the tail (the#0throw-site frame is preserved) with a…(truncated)marker.
Graceful drain of long-lived connections
On SIGTERM the server stops accepting, sends GOAWAY to HTTP/2 clients and closes idle HTTP/1.1 keep-alives, and ends every open long-lived stream (SSE and other flush loops) promptly and uncatchably — register_shutdown_function() callbacks still run and error_get_last()['message'] reads Request cancelled (shutdown); a try/catch (\Throwable) around the streaming loop cannot swallow it.
Ordinary in-flight requests are left alone: they get the whole DRAIN_TIMEOUT_SECONDS window to finish, and only requests still running at the deadline are cancelled, after which the process exits within ~2s. The DRAIN_TIMEOUT_SECONDS default is lowered from 30 to 25 so the full sequence — drain, ~2s unwind, telemetry flush — fits inside Kubernetes' default 30-second grace period. Set your orchestrator's grace period above DRAIN_TIMEOUT_SECONDS + 2s.
Buggregator profiler export
The profiler's HTTP push can now wrap xhprof data in the envelope Buggregator's POST /api/profiler/store expects, so profiles land in a Buggregator instance with correct project grouping and tag filtering. PROFILER_EXPORT_BUGGREGATOR toggles it; when unset it auto-detects an export URL whose path ends in /api/profiler/store. PROFILER_EXPORT_APP_NAME sets the project name and PROFILER_EXPORT_TAGS sets tags from a key=value,… list.
Fixes
- Worker-mode per-request cancellation (client abort or request timeout) could target whichever request was most recently accepted on the worker instead of the one being cancelled — so a timeout or disconnect on one multiplexed request could abort an unrelated in-flight request sharing the same worker.
- Once any request on a worker called
oxphp_finish_request(), every other stream multiplexed onto that worker silently dropped the output of its subsequentoxphp_stream_flush()calls.
Upgrade from 0.9.0
Two items can change behaviour on upgrade; the rest are error-on-misconfiguration.
-
The process drops to
www-databy default. Started as root on an image with awww-dataaccount (the official image),oxphp serve/oxphp runnow serve aswww-data, not root. Action: if you relied on serving as root, pass--user=root. Orchestrator-level user drops still take precedence and are unaffected. -
Config is validated fail-closed. A half-configured TLS pair, a non-UTF-8
TLS_CERT/TLS_KEY, a malformedASYNC_*, an invalidSUPERGLOBALS_ENABLEDonoxphp run, and aTLS_MIN_VERSIONin a foreign syntax (e.g.TLSv1.2, now validated even without TLS) all abort startup. Action: runoxphp config --checkagainst your environment before rolling out; fix or unset the offending variable. An exactly-empty value still means unset. -
A
QUERYrequest without aContent-Typeheader now returns400, not415. This was the only server-generated415. Action: if you keyed a custom error page on it, renameERROR_PAGES_DIR/415.htmlto400.html. -
Profiler export envelope resolution changed for pre-existing configs.
PROFILER_EXPORT_XHGUI=falsenow disables the xhgui envelope even on a/run/importURL, and envelope auto-detection keys only on each tool's canonical endpoint path. Action: if you pointPROFILER_EXPORT_URLat a non-standard path, setPROFILER_EXPORT_XHGUI/PROFILER_EXPORT_BUGGREGATORexplicitly.
Full changelog: CHANGELOG.md · v0.9.0...v0.10.0