Fluxheim 1.3.1 adds the first production-compatible PHP path through
php-fpm. PHP is not part of the default binary. Each PHP runtime changes the
security model from serving files and proxying HTTP to executing user code, so
every PHP path is opt-in at compile time and opt-in per vhost or route.
Use this implementation model:
php-fpm: stable backwards-compatible path for PHP applications. This is implemented in1.3.1.- Managed
php-fpm: zero-admin deployment mode inside the existingphp-fpmfeature. Fluxheim generates a minimal private php-fpm pool, spawns and supervises php-fpm, and connects to its private socket while still using the same FastCGI request path as external php-fpm.
As of the current review, fastcgi-client 0.11.1 is available for the php-fpm
path under Apache-2.0. Pure-Rust PHP/phprs support is no longer planned for the
1.3 line because managed php-fpm covers the useful zero-admin deployment goal
without adopting an immature interpreter. ripht-php-sapi 0.1.0-rc.7 remains an
embedding reference only. Turbine is not a Fluxheim runtime target; it is better
treated as an external PHP application server that Fluxheim can reverse-proxy to
if an operator chooses it.
Implemented feature flags:
php = ["proxy", "web"]
php-fpm = ["php", "dep:fastcgi-client", "dep:tokio", "fastcgi-client/runtime-tokio"]php-fpm is the only PHP runtime feature in the 1.3 line. Managed php-fpm is a
runtime configuration mode under the same feature because it changes process
lifecycle, not the request protocol.
The default feature set must not include php.
Release order:
1.3.0: shared ingress/TLS feature-graph split and focused image/profile cleanup.1.3.1:php-fpmFastCGI bridge, WordPress-style front-controller support, strict script resolution, bounded request/response buffering, split-cookie normalization, and browser-validated WordPress login/admin flows.1.3.3: focused php-fpm hardening and compatibility fixes found during production tests.1.3.7: managed php-fpm mode as a config option under the existingphp-fpmfeature, not as a separate Cargo runtime. The goal is a single-binary operator experience while retaining normal php-fpm request isolation and compatibility.- Pure-Rust PHP/phprs is intentionally out of scope for the 1.3 line. Revisit only if an upstream interpreter has mature compatibility, maintenance, and security evidence.
Minimal vhost TOML:
[[vhosts]]
name = "php.example.test"
hosts = ["php.example.test"]
[vhosts.php]
enabled = true
runtime = "php-fpm"
root = "/srv/sites/php.example.test/public"
index = "index.php"
allowed_extensions = ["php"]
deny_path_prefixes = ["/wp-content/uploads/"]
request_timeout_secs = 30
max_request_body_bytes = "16MiB"
max_response_bytes = "64MiB"
max_response_header_bytes = "64KiB"
pass_request_headers = true
pass_request_body = true
stderr_log = true
stderr_max_bytes = "2KiB"
hide_response_headers = ["x-powered-by"]
ignore_origin_cache_headers = false
intercept_error_statuses = []
# Use "split" only when the application expects PATH_INFO after script.php.
path_info = "disabled"
[[vhosts.php.error_pages]]
status = 502
path = "/502.html"
[vhosts.php.error_pages.web]
root = "/srv/errors"
index_files = ["index.html"]
[vhosts.php.fpm]
tcp = "127.0.0.1:9000"
allow_private_tcp_upstreams = true
# socket = "/run/php/php-fpm.sock"Managed php-fpm mode keeps the runtime selection in
[vhosts.php.fpm] instead of adding a new compile-time PHP runtime:
[vhosts.php.fpm]
mode = "managed" # "external" remains the default
php_fpm_binary = "/usr/sbin/php-fpm"
workers = 4
max_requests_per_worker = 1000
socket_dir = "/run/fluxheim/php"
process_manager = "static" # "static", "dynamic", or "ondemand"
# listen_backlog = 128
# Optional socket ownership controls when php-fpm changes socket ownership
# after dropping privileges. Defaults to a private 0600 socket.
# listen_owner = "fluxheim"
# listen_group = "php"
# listen_mode = "0660"
# request_terminate_timeout_secs = 30
# request_slowlog_timeout_secs = 5
# clear_env = true
# catch_workers_output = true
# decorate_workers_output = true
# Optional, configure both together when php-fpm starts as root and should drop
# worker privileges. Pair with listen_owner/listen_group/listen_mode when
# Fluxheim itself is not running as the same user.
user = "fluxheim"
group = "fluxheim"Managed mode generates a minimal private php-fpm config, creates a
Fluxheim-owned Unix socket, supervises the php-fpm master process, and shuts it
down cleanly on reload or gateway shutdown. It is not implemented as a
persistent php-cli stdin/stdout worker pool for production apps, because
normal WordPress, Laravel, Symfony, forum, and wiki applications expect
per-request PHP isolation.
The generated socket, config, pid, and php-fpm log files live under
socket_dir. Normal reload/drop cleanup removes the generated control files,
but forced process termination can leave stale files behind because destructors
do not run. They are safe to remove when Fluxheim is stopped.
Managed pools support the php-fpm process manager modes operators usually tune
in www.conf:
process_manager = "static"starts exactlyworkerschildren.process_manager = "dynamic"usesworkersaspm.max_childrenand requiresmin_spare_serversandmax_spare_servers;start_serversandmax_spawn_rateare optional.process_manager = "ondemand"usesworkersaspm.max_childrenand can setprocess_idle_timeout_secs.
Fluxheim also writes bounded php-fpm directives for listen_backlog,
listen_owner, listen_group, listen_mode,
request_terminate_timeout_secs,
request_terminate_timeout_track_finished, request_slowlog_timeout_secs,
request_slowlog_trace_depth, clear_env, catch_workers_output,
decorate_workers_output, session_save_path, and upload_tmp_dir.
session_save_path and upload_tmp_dir are created with owner-only
permissions when missing; if the managed pool drops to another user/group,
make those directories writable by that PHP worker identity before starting
Fluxheim.
The PHP handler runs before static fallback. Existing non-PHP files under the
PHP root are declined so the normal static server can serve assets. Missing
paths use the configured front controller, normally /index.php. Explicit
.php requests are executed through php-fpm. Static serving must never return
PHP source when PHP execution fails.
Fluxheim normalizes multiple inbound Cookie header lines into one CGI
HTTP_COOKIE value before calling php-fpm. This matters for WordPress and other
PHP applications behind HTTP/2 or intermediaries that split cookies across
multiple header fields.
For application-specific examples, including WordPress, WordPress Multisite,
Laravel, Symfony, Flarum, MediaWiki, phpBB, XenForo, MyBB, and
Discourse-as-proxy, see
PHP-FPM Application Recipes.
Fluxheim's php-fpm integration intentionally implements the normal web-serving
FastCGI subset: one FCGI_RESPONDER request at a time per selected backend
connection, BEGIN_REQUEST, bounded PARAMS, optional STDIN, collected
STDOUT, bounded and sanitized STDERR, and END_REQUEST. Opt-in keepalive
may reuse an idle backend connection after a request completes, but Fluxheim does
not multiplex concurrent FastCGI request IDs on one connection.
FCGI_AUTHORIZER, FCGI_FILTER, FastCGI management records, and application
protocols that require multiplexed request state are unsupported in 1.3.x.
They are not required for standard PHP-FPM WordPress, Laravel, Symfony, or
legacy PHP web serving. Operators that need those roles should keep them behind
a dedicated FastCGI-aware component until Fluxheim has an explicit design and
test suite for them.
The PHP layer must implement these checks before any runtime is production eligible:
- Canonicalize the vhost PHP root and target script path.
- Reject traversal, symlink escapes, empty script names, and non-file script targets.
- Never build
SCRIPT_FILENAMEthrough string concatenation alone. - Deny dotfiles and hidden path segments by default.
- Deny configured PHP execution path prefixes before contacting php-fpm.
- Never pass arbitrary process environment to PHP.
- Use a small allow-list for CGI/FastCGI params.
- Reject decoded control characters in path-derived FastCGI params and avoid protocol-relative directory-slash redirects.
- Set
SCRIPT_NAME,SCRIPT_FILENAME,DOCUMENT_ROOT,REQUEST_METHOD,QUERY_STRING,REQUEST_URI,SERVER_NAME,SERVER_PORT, andSERVER_PROTOCOLexplicitly. - Translate safe inbound HTTP headers to CGI
HTTP_*params, includingHTTP_HOST, while droppingProxyto avoid HTTPoxy exposure. - Set TLS-related CGI context (
HTTPSandREQUEST_SCHEME) from the downstream connection state. - Set
REDIRECT_STATUS=200for php-fpm compatibility with common PHP hardening defaults. - Treat
PATH_INFOas disabled by default; enablepath_info = "split"only when the application expects safe trailing segments afterscript.php. - Allow administrator-controlled custom FastCGI params only after validating
names and values, and never let them override Fluxheim-managed CGI params
such as
SCRIPT_FILENAME,CONTENT_LENGTH,HTTPS, orHTTP_PROXY. - Enforce global and PHP-specific request body limits for both declared and streaming bodies.
- Support explicit
pass_request_headersandpass_request_bodyswitches. Body pass-through disabled still drains and limits the downstream body before sending empty FastCGI stdin. - Apply runtime request timeouts and connection timeouts.
- Log php-fpm STDERR only when
stderr_logis enabled, sanitize controls, and cap each log message withstderr_max_bytes. - Remove selected php-fpm response headers with
hide_response_headersbefore the response reaches clients. - Optionally ignore PHP-generated
Cache-Control,Expires, andPragmaheaders withignore_origin_cache_headersbefore response policy is applied. - Cap response header bytes returned by PHP.
Implemented as
php.max_response_header_bytes, defaulting to64KiB. - Parse PHP-generated headers strictly; reject malformed status lines and header injection.
- Strip hop-by-hop php-fpm response headers, including
Connection-named headers andTransfer-Encoding, before Fluxheim frames the client response. - Optionally intercept selected PHP 4xx/5xx responses with
intercept_error_statusesand replace them with Fluxheim-generated error responses. - Serve configured static PHP error pages with
[[vhosts.php.error_pages]]or[[vhosts.routes.php.error_pages]]; an error-page entry also intercepts that status and falls back to Fluxheim's generated error if the static page cannot be served. - Log PHP STDERR only through size-limited sanitized logs.
- Keep php-fpm sockets private and validate Unix socket path permissions where possible.
- Prefer php-fpm process isolation for production until embedded runtimes prove safe concurrency and reload behavior.
This is the compatibility-first path. Fluxheim acts as a FastCGI client:
- Match an eligible PHP request from vhost config.
- Resolve and canonicalize the target script under the configured root.
- Build FastCGI params from a strict allow-list.
- Bounded-buffer the request body to php-fpm.
- Bounded-buffer and parse FastCGI STDOUT into HTTP headers and body.
- Send PHP STDERR to sanitized logs and metrics.
max_response_bytes defaults to 64MiB and is capped at 64MiB. Hardened edge
deployments can lower it per vhost or route to reduce per-request memory
exposure for PHP responses. Large generated files should use X-Accel-Redirect
or X-Sendfile so Fluxheim serves the file path directly instead of buffering
the PHP output in memory.
Prefer Unix sockets first for local/rootless deployments. TCP support is useful for separate php-fpm containers, but must require explicit config.
Current tests cover config validation, traversal rejection, disabled PATH_INFO
behavior, safe CGI header translation with HTTPoxy mitigation, custom FastCGI
param validation, malformed FastCGI response headers, php-fpm timeout
classification, spooled request-body replay and cleanup, and bounded STDERR
handling. Local WordPress php-fpm and proxied WordPress TLS smoke tests live in
scripts/smoke_wordpress_php_fpm.sh and
scripts/smoke_wordpress_proxy_tls.sh; keep running them as release evidence
when PHP behavior changes. The local WordPress smoke accepts external,
managed-static, managed-dynamic, managed-ondemand, managed-all, or
managed-respawn, or both so the same install/login/admin flow can verify an
operator-managed php-fpm container, every Fluxheim-managed php-fpm process
manager mode, and the managed php-fpm post-start crash respawn watchdog.
scripts/smoke_fluxheim_php_wolfi.sh verifies the self-contained Wolfi PHP
image path with bundled php-8.5-fpm and managed php-fpm enabled.
Managed php-fpm starts the php-fpm master with a cleared environment and a
minimal PATH, so Fluxheim process secrets such as admin tokens are not
inherited by the child process. On reload/shutdown, Fluxheim asks the managed
php-fpm master to terminate gracefully before escalating to a forced kill after
a short deadline. A per-pool watchdog monitors the php-fpm master after startup
and respawns it with bounded backoff if it crashes, so managed PHP service can
recover without a Fluxheim config reload.
When a managed php-fpm pool drops workers to a different user/group,
configure listen_owner and listen_group when the php-fpm master should chown
the private socket for Fluxheim, and use listen_mode = "0660" only when a
shared service group is intentional. The default remains listen_mode = "0600"
for single-user/rootless deployments.
1.3.3 php-fpm hardening status:
- Connection pooling to php-fpm with idle pruning. Implemented as opt-in
php.fpm.keepalive. - Safe FastCGI keep-connection reuse where the client/runtime supports it.
Implemented for the
fastcgi-clientkeep-alive path. - True streaming request and response bodies. Request-body disk replay is in place for large PHP bodies; direct downstream-to-FastCGI and FastCGI-to-client streaming remain future work.
- Chunked upload disk-spooling before php-fpm dispatch. Implemented with
php.request_body_spool_threshold_bytesandphp.request_body_spool_dirso large uploads keep exact FastCGICONTENT_LENGTHwithout retaining the whole request body in memory. Both settings must be configured together, and the threshold must be lower thanphp.max_request_body_byteswhen both are set on the same PHP action. Existing spool directories must not be group/world writable, and runtime spool creation rechecks permissions before writing upload bodies. - Custom FastCGI params in config. Implemented as
[vhosts.php.params]and[vhosts.routes.php.params]with protected core CGI params. - Path mapping for separate Fluxheim/php-fpm container filesystem roots.
Implemented as
php.fpm_rootfor FastCGIDOCUMENT_ROOT,SCRIPT_FILENAME, andPATH_TRANSLATEDmapping. Existing localphp.fpm_rootpaths are rejected when they include symlink components; missing absolute values are allowed for split-container paths that exist only inside the php-fpm environment. - PHP root override for split container filesystem layouts. Implemented with
php.fpm_rootfor the path sent to php-fpm andphp.resolve_root_symlinkfor opt-in finalphp.rootsymlink resolution in current-release deploy layouts. - Typed
try_files/front-controller presets for WordPress, Laravel/Symfony, and strict=404PHP locations. Implemented asphp.try_files = "front-controller","wordpress", or"strict". - WordPress PHP-side migration preset. Implemented as
php.preset = "wordpress"to combine WordPress front-controller behavior with deny prefixes for common upload/file execution paths. - Configurable safe
PATH_INFOsplitting. Implemented asphp.path_info = "disabled"or"split"; the legacy"strict"spelling remains accepted as an alias for"split". - Canonical directory slash redirects for directory index PHP apps.
Implemented as a
308redirect before executing directoryindex.phpscripts. - Explicit request header/body pass-through switches for advanced migrations.
Implemented as
php.pass_request_headersandphp.pass_request_body, both defaulting totrue. - PHP-specific in-flight request cap. Implemented as
php.max_in_flight, defaulting to8, so buffered PHP-FPM responses cannot multiply without a route-local or vhost-local bound. X-Accel-Redirect/X-Sendfilesupport. Implemented for PHP-assisted static offload underphp.root;X-Sendfilepaths are mapped fromphp.fpm_rootfor split containers, and configured PHP script extensions are refused as offload targets.X-Accel-Expiresresponse control handling. Implemented for PHP responses: Fluxheim strips the internal header, maps valid TTLs toCache-ControlandExpires, treats zero or past expiries asno-store, and usesprivatedirectives for responses that set cookies. If the origin response already has restrictive cache policy such asCache-Control: private,no-store,no-cache, orPragma: no-cache, Fluxheim preserves that policy and does not promote the response to shared-cache eligibility.fastcgi_intercept_errors-style integration with Fluxheim error pages. Initial generic interception implemented asphp.intercept_error_statuses.- Response header hide/pass/ignore controls for PHP backends.
Initial hide controls implemented as
php.hide_response_headers;php.ignore_origin_cache_headersremoves PHP-generatedCache-Control,Expires, andPragma; hop-by-hop PHP response headers are stripped by default. - STDERR capture/truncation/severity controls and fatal-error matching.
Initial controls implemented as
php.stderr_log,php.stderr_log_level,php.stderr_max_bytes, andphp.stderr_failure_patternsfor marking matching STDERR output as an invalid php-fpm response eligible for opt-in safe-method retry/failover. - Initial php-fpm TCP upstream list and failover. Implemented as
php.fpm.tcp_upstreamswith round-robin endpoint selection and safe-method failover on connection failures and connect timeouts. Upstream lists are capped at 64 entries and reject duplicate authorities. - Retry policy for connection failures and connect timeouts on configured safe
methods. Implemented as
php.fpm.max_retriesandphp.fpm.retry_methods, withphp.fpm.retry_timeout_secsas an optional per-request retry window; php-fpm connect timeouts are bounded byphp.request_timeout_secs, and request timeouts are not retried to avoid duplicating PHP side effects. Withtcp_upstreams, Fluxheim tries enough endpoints to cover the configured list for safe methods even whenmax_retries = 0. Broader status/invalid-header retry policy is available as opt-inphp.fpm.retry_invalid_responseandphp.fpm.retry_statuses. - PHP-specific Prometheus metrics for bounded request totals, durations, STDERR,
retries, and keepalive pool state. Multi-upstream keepalive pools use stable
indexed pool labels for Prometheus gauges. Implemented as
fluxheim_php_requests_total,fluxheim_php_request_duration_seconds,fluxheim_php_stderr_events_total,fluxheim_php_fpm_retries_total,fluxheim_php_fpm_pool_idle_connections, andfluxheim_php_fpm_pool_events_total; OpenTelemetry metrics export follows the existing metrics exporter path when enabled. OTLP request spans include low-cardinalityfluxheim.php.runtimeandfluxheim.php.outcomeattributes for PHP-handled requests whenotel-otlpis enabled. - FastCGI cache-specific convenience config.
Initial convenience preset implemented as
cache.preset = "wordpress", expanding common admin/login/legacy endpoint, cookie-prefix, query, and authorization bypasses. The underlying cache predicate lists are bounded to prevent unbounded per-request matching. - FastCGI cache compatibility presets: cache keys, status TTLs, bypass/no-cache predicates, cache lock, stale-on-error/timeout, background refresh, and purge. The WordPress cache preset now also supports the common NGINX rule that any non-empty query string bypasses shared cache lookup and storage.
- WordPress cache-plugin migration presets for logged-in/commenter cookie
bypass, admin/login exclusions, and denial of PHP execution under
uploads/files directories. The first cache safety preset is
cache.preset = "wordpress". Initial execution denial implemented asphp.deny_path_prefixes; this is defense in depth above local filesystem permissions and stops Fluxheim from sending matching PHP scripts to php-fpm. - Super Cache/W3TC-style static-file fallbacks remain future work. They need a typed static-file probing design so Fluxheim can avoid broad rewrite-string interpolation while still matching common WordPress plugin cache layouts.
- FastCGI multiplexing, authorizer, and filter-role review. Documented as
unsupported for
1.3.x; Fluxheim supports the normal one-request-at-a-timeFCGI_RESPONDERPHP-FPM web-serving subset.
Turbine-style PHP runtimes are outside Fluxheim's embedded PHP runtime plan. They are app servers with their own listener, worker, TLS, lifecycle, and metrics model. Operators can run one behind Fluxheim like any other HTTP upstream:
[[vhosts]]
name = "turbine-app"
hosts = ["turbine.example.test"]
[vhosts.proxy]
upstreams = ["turbine-app:8080"]
upstream_tls = falseFluxheim should not duplicate that model as an embedded PHP runtime unless a future project exposes a small auditable library API with a clearly safer security boundary than reverse proxying.
Pure-Rust PHP/phprs is not a Fluxheim runtime target for the 1.3 line. Managed php-fpm provides the useful single-binary operational model while preserving the normal php-fpm compatibility and isolation boundary. Revisit only if a future interpreter has mature PHP/framework compatibility, security testing, performance evidence, and a clear maintenance signal.
Fluxheim 1.3.6 also completed the adjacent admin API JSON cleanup. This is not
part of the PHP runtime, but it landed in the same hardening window: dynamic
admin responses now serialize through serde_json::to_vec while preserving the
existing admin API schemas and regression coverage.
PHP runtime selection and PHP runtime process settings should be classified as process-upgrade changes until a runtime proves safe snapshot-only reload semantics.
Per-vhost PHP routing policy may later become snapshot-safe, but only after path resolution, runtime handles, and request isolation are immutable per runtime snapshot.
Operational metrics include bounded Prometheus request totals, durations, STDERR event counts, php-fpm retry-attempt counts, and keepalive pool idle and event metrics for the PHP handler.