TLS

OxPHP handles TLS termination natively. No reverse proxy or external SSL library is required. Once configured, the server accepts HTTPS connections and negotiates the best available protocol automatically.

How it works

To enable TLS, set TLS_CERT and TLS_KEY to point at your PEM-encoded certificate and private key files. Once both are set, the server listens for HTTPS connections on the address specified by LISTEN_ADDR.

The TLS handshake happens before any HTTP processing:

  1. A TCP connection arrives on LISTEN_ADDR.
  2. The server performs a TLS handshake using the configured certificate and key.
  3. Protocol negotiation (ALPN) selects HTTP/2 (h2) or HTTP/1.1 based on client support.
  4. The encrypted connection is passed to the HTTP layer for normal request handling.
Note

When TLS is enabled, header and request timeouts apply per-request after the TLS handshake completes.

Configuration

Variable Default Description
TLS_CERT (unset) Path to PEM-encoded certificate file. Both TLS_CERT and TLS_KEY must be set to enable TLS
TLS_KEY (unset) Path to PEM-encoded private key file
TLS_MIN_VERSION 1.2 Minimum accepted TLS protocol version: 1.2 or 1.3. Any other value is a startup error
LISTEN_ADDR 0.0.0.0:80 Address and port to listen on. Change to 0.0.0.0:443 when using TLS

If only one of TLS_CERT or TLS_KEY is set, the server refuses to start: a half-configured pair is almost always a typo'd variable name, and silently serving plain HTTP on a port meant for HTTPS would fail open. An empty value (TLS_CERT=, as produced by ${TLS_CERT:-}-style substitutions) is treated as unset; when neither is set, the server starts in plain HTTP mode.

Supported protocols

Capability Detail
TLS versions TLS 1.2 and TLS 1.3 (floor configurable via TLS_MIN_VERSION)
ALPN protocols h2 (HTTP/2) and http/1.1, negotiated in that order
Client certificates Not supported (no mutual TLS)

Minimum protocol version

By default the server accepts TLS 1.2 and TLS 1.3. Deployments that must refuse TLS 1.2 (PCI-DSS scopes, internal policies mandating 1.3-only) can raise the floor:

bash
TLS_MIN_VERSION=1.3

With the floor at 1.3, a TLS 1.2 ClientHello is rejected during the handshake with a protocol_version alert; TLS 1.3 clients are unaffected.

An invalid value (1.1, 1.0, or a typo) is a hard startup error, not a silent fallback: a mistyped security floor must fail loudly rather than quietly run with a weaker configuration. The value is validated at startup even when TLS itself is not enabled, and oxphp config --check reports the same error before any restart. An empty value (TLS_MIN_VERSION=, as produced by ${TLS_MIN_VERSION:-}-style substitutions) is treated as unset. TLS 1.0 and 1.1 are not supported at all and cannot be enabled.

Cipher suites are not configurable, by design

The built-in TLS implementation ships only modern AEAD cipher suites (AES-GCM and ChaCha20-Poly1305 with ECDHE key exchange). There is no RC4, no CBC-mode suite, no export cipher to disable, so the classic "restrict weak ciphers" knob has nothing to remove. TLS_MIN_VERSION is a protocol floor only; it does not change the cryptography provider and is not a FIPS compliance switch.

HTTP/2

OxPHP serves HTTP/2 and HTTP/1.1 on the same port. The protocol is chosen per connection, and there is no setting to turn HTTP/2 on or off:

  • Over TLS, the protocol is negotiated during the handshake via ALPN. OxPHP advertises h2 then http/1.1, so HTTP/2-capable clients get HTTP/2 and everyone else falls back to HTTP/1.1 transparently.
  • Without TLS (h2c), OxPHP detects the HTTP/2 connection preface and serves cleartext HTTP/2 to clients that connect with prior knowledge (e.g. curl --http2-prior-knowledge). Clients that don't speak HTTP/2 keep using HTTP/1.1 on the same port. (The Upgrade: h2c handshake is not used — HTTP/2 over cleartext requires prior knowledge.)

HTTP/2 flow-control windows are raised above the protocol defaults — 8 MB per connection and 4 MB per stream, versus the 64 KB default — to avoid stalls on typical PHP responses, which are usually larger than one default window.

PHP sees the negotiated protocol in $_SERVER['SERVER_PROTOCOL'] ("HTTP/2" or "HTTP/1.1").

Verify

bash
# HTTP/2 over TLS (negotiated via ALPN) curl -k --http2 -I https://localhost/ # Cleartext HTTP/2 (h2c, prior knowledge) curl --http2-prior-knowledge -I http://localhost/

Look for HTTP/2 200 in the response line.

Connection limits

OxPHP applies per-connection HTTP/2 limits to bound the amplification a single TCP connection can exert on the PHP worker pool. Each accepted stream becomes a queued PHP request, so an unbounded stream count from one connection would saturate the pool on its own.

Variable Default Description
H2_MAX_CONCURRENT_STREAMS PHP_WORKERS_MAX × 4 (min 32) Maximum simultaneous open streams per connection. Excess streams receive REFUSED_STREAM
H2_MAX_PENDING_RESET 20 Maximum RST_STREAM frames queued before the connection is closed (CVE-2023-44487 Rapid Reset protection)
H2_MAX_HEADER_LIST_BYTES 65536 Maximum total decoded header bytes per request (HPACK bomb guard)
H2_KEEPALIVE_INTERVAL_SECS 20 Seconds between HTTP/2 PING frames; 0 disables keepalive
H2_KEEPALIVE_TIMEOUT_SECS 10 Seconds to wait for a PING reply before closing the connection

PHP_WORKERS_MAX is the maximum worker count set by PHP_WORKERS. For a dynamic range like 4:16, the maximum (16) is used. The default scales with workers so that legitimate concurrent page loads on one connection do not create more queue pressure than the pool can absorb.

Trade-off

H2_MAX_CONCURRENT_STREAMS is intentionally tuned to pool capacity, not to browser multiplexing behaviour. Browsers send dozens of asset requests over a single HTTP/2 connection; those that exceed the cap receive REFUSED_STREAM and are automatically retried by the browser in the next batch. This adds a small latency penalty on heavy fan-out pages (many assets per connection) but prevents a single connection from filling the PHP request queue. If your site has many large-asset pages and the default causes measurable latency, raise H2_MAX_CONCURRENT_STREAMS explicitly rather than increasing PHP_WORKERS.

Supported key types

The private key file must contain a single PEM-encoded key in one of the following formats:

  • RSA
  • ECDSA (e.g., prime256v1, secp384r1)
  • Ed25519

The certificate file may contain one or more PEM-encoded certificates. For production use, include the full chain: your server certificate followed by any intermediate certificates.

Self-signed certificate for development

Generate a self-signed ECDSA certificate for local development:

bash
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ -keyout key.pem -out cert.pem -days 365 -nodes \ -subj "/CN=localhost"

Then configure OxPHP to use the generated files:

bash
TLS_CERT=./cert.pem TLS_KEY=./key.pem LISTEN_ADDR=0.0.0.0:443

Troubleshooting

Server starts but TLS is not active

OxPHP requires both TLS_CERT and TLS_KEY to be set. If only one of them is set, the server refuses to start (TLS_CERT is set but TLS_KEY is missing — both are required to enable TLS (unset TLS_CERT to serve plain HTTP)), and oxphp config --check reports the same misconfiguration before deployment. If neither is set, plain HTTP is the normal, silent default — but if the variables are present and empty (a ${VAR:-} substitution that rendered empty, e.g. a broken secret mount), a startup warning (TLS variable(s) set but empty — TLS disabled, serving plain HTTP) leaves a trace. A warning is also logged when TLS_MIN_VERSION=1.3 is set while TLS is not enabled. Confirm both variables are set:

bash
docker exec <container> env | grep TLS
TLS_KEY: no private key found in ... error at startup

The key file is empty, corrupt, or contains only a certificate. Verify that the key file contains a -----BEGIN ... PRIVATE KEY----- block:

bash
grep "PRIVATE KEY" key.pem

If the key is missing, regenerate the certificate and key pair.

Certificate fails to load at startup

The certificate file is empty or corrupt, and startup aborts with an error from the TLS layer. Verify that the cert file contains at least one -----BEGIN CERTIFICATE----- block:

bash
grep "BEGIN CERTIFICATE" cert.pem
Clients see a certificate chain error

The server is sending only the leaf certificate without intermediate certificates. Concatenate the full chain into a single PEM file:

bash
cat cert.pem intermediate.pem > fullchain.pem

Then set TLS_CERT=./fullchain.pem.

Certificate expired

OxPHP reads certificate files at startup and holds them in memory. Renewing the certificate on disk has no effect until the server restarts.

Fix: Restart OxPHP after certificate renewal. Automate this with your certificate renewal tool (e.g. certbot's --deploy-hook option).

Cannot serve HTTP and HTTPS on the same port

OxPHP listens on a single port. To support both protocols simultaneously, use a reverse proxy (Caddy, Traefik, nginx) that handles the HTTP-to-HTTPS redirect, or run a second OxPHP instance on port 80 dedicated to redirecting traffic.

Docker example

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.10.0 ports: - "443:443" environment: LISTEN_ADDR: "0.0.0.0:443" TLS_CERT: "/etc/ssl/oxphp/cert.pem" TLS_KEY: "/etc/ssl/oxphp/key.pem" volumes: - ./app:/var/www/html:ro - ./certs:/etc/ssl/oxphp:ro

Best practices

  • Include intermediate certificates in the PEM chain. Place the server certificate first, followed by intermediates in order, so clients can verify the full trust path.
  • Automate certificate renewal. Use certbot or acme.sh to renew certificates before expiry, then restart OxPHP to load the new files.
  • Use a reverse proxy for HTTP-to-HTTPS redirection. OxPHP does not serve both HTTP and HTTPS on the same port simultaneously.

Notes

  • OxPHP does not depend on OpenSSL. TLS is handled by a built-in implementation, eliminating a common source of external library CVEs.
  • Certificate and key files are read at startup only. Updating certificates on disk requires restarting the server.

See also