Fluxheim config is TOML. Unknown fields are rejected, so misspelled settings
fail during --check-config instead of being ignored.
Inspect a config before running it:
fluxheim --check-config --config path/to/fluxheim.tomlFor deployment preflight, use --validate-config. This performs the same
static validation and also builds the runtime proxy state, so missing static
web roots and other startup-blocking filesystem issues fail before systemd
starts the service:
fluxheim --validate-config --config /etc/fluxheim/fluxheim.tomlWhen debugging a container or mounted config from outside the runtime
environment, use the release-asset tester. --no-runtime-paths skips only
server.process runtime path inspection, which is useful when /run/fluxheim
is not mounted locally, while other config semantics and profile checks still
run:
fluxheim-config-tester --config /etc/fluxheim/fluxheim.toml --profile web-php --no-runtime-pathsFor split config directories, Fluxheim reads *.toml files in sorted order:
fluxheim --check-config --config examples/conf.dWhen the config path is a file, Fluxheim loads only that file unless the file
sets include_conf_d = true. With that opt-in, visible *.toml files from a
sibling conf.d/ directory load after the main file. When the config path is a
directory, Fluxheim loads visible *.toml files in that directory first and
then visible *.toml files in its conf.d/ child. Files are loaded in lexical
order within each directory.
Relative filesystem paths are resolved from the config file directory. Config sources must be real TOML files or real directories. Fluxheim rejects a symlink used as the top-level config source, rejects config sources below a symlinked directory, and ignores symlinked TOML entries inside split config directories, so a reload cannot be redirected through an unexpected filesystem pointer. Each TOML file is size-limited to 1 MiB; large deployments should use a split config directory instead of one huge file. Split config directories are limited to 256 visible TOML files. Configured filesystem paths are also rejected when any existing path component is a symlink; missing final directories may still be created by the owning runtime module, but never through a symlinked prefix.
[server] controls listeners, default vhost selection, trusted proxies, and
global request limits.
[server]
listen = ["127.0.0.1:8080"]
tls_listen = []
default_vhost = "example.test"
trusted_proxies = ["127.0.0.1"]
proxy_protocol = "off"
regex_enabled = false
[server.limits]
max_request_header_bytes = "64KiB"
max_uri_bytes = "8KiB"
max_request_headers = 100
max_request_body_bytes = "16MiB"
[server.process]
daemon = false
error_log = "/run/fluxheim/error.log"
pid_file = "/run/fluxheim/fluxheim.pid"
upgrade_sock = "/run/fluxheim/fluxheim-upgrade.sock"
certificate_reload_sock = "/run/fluxheim/fluxheim-cert-reload.sock"
threads = 1
listener_tasks_per_fd = 1
work_stealing = true
upstream_keepalive_pool_size = 128
max_retries = 16
grace_period_seconds = 10
graceful_shutdown_timeout_seconds = 30
[server.https_redirect]
enabled = false
status = 308
# target_port = 8443
[server.host_routing]
strict = falseNotes:
listenandtls_listencannot both be empty unless[stream].enabled = truesupplies dedicated TCP stream listeners.- TLS listeners are explicit through
tls_listen; Fluxheim does not infer TLS from port numbers. listenandtls_listenare each capped at 64 entries.default_vhost, when set, must match a configured[[vhosts]].name.[server.host_routing].strict = falsepreserves compatibility by falling back todefault_vhostfor missing, invalid, or unknown host names. Set it totruein hardened multi-tenant deployments to reject missing or invalid host identity with400and unknown hosts with421.- If vhosts live in a sibling
conf.ddirectory and--configpoints at the main file, set top-levelinclude_conf_d = true; alternatively point--configat the config directory so visible.tomlfiles are loaded in sorted order. trusted_proxiesshould contain only peers you operate, such as a container gateway, Cloudflare, or a trusted edge proxy. When the direct peer is trusted, Fluxheim walksX-Forwarded-Forfrom right to left and restores the last non-trusted hop for generated client-IP headers, equivalent to nginxreal_ip_recursive on. The list is capped at 512 entries.proxy_protocoldefaults tooff. Set it tov1orv2only on listeners reached exclusively through trusted load balancers or edge proxies that send HAProxy PROXY protocol before TLS/HTTP/stream bytes. Fluxheim requiresserver.trusted_proxieswhen this is enabled, rejects direct peers outside that trust list before parsing the header, and restores the PROXY source address before TLS, HTTP, and stream handling. The v2 parser supports TCP4/TCP6 and LOCAL/UNSPEC frames, skips bounded TLV payloads, and rejects unsupported address families or oversized v2 payloads.[server.process]maps safe process settings into Pingora'sServerConf. Changes to these values require a process upgrade, not a live snapshot reload. Keepthreadsconservative in containers because Pingora allocates worker threads per service.pid_file,upgrade_sock,certificate_reload_sock, and optionalerror_logmust not contain parent traversal, must not be below symlinked existing parent directories, and on Unix must not use a group- or world-writable existing parent such as/tmp. Use a dedicated runtime directory such as/run/fluxheim.certificate_reload_sockis a local Unix-domain control socket used byfluxheim-acmeto request certificate-handle reloads after external ACME renewal. It is not a general admin API.[server.https_redirect]is disabled by default. When enabled, cleartext requests receive a direct HTTPS redirect before static serving or proxying. It requires at least onetls_listenaddress.statusmay be301,302,307, or308;308is the default.target_portis optional and should be used only when clients must be redirected to a non-default HTTPS port. Redirects require a syntactically safeHostheader, otherwise Fluxheim returns400instead of constructing a riskyLocation.
[stream] is disabled by default and requires a build with the
stream-proxy feature. Stream routes are raw L4 TCP services. They do not run
HTTP routing, headers, cache, compression, auth subrequests, PHP, or web
serving logic. The current stream datapath and listener loop are
Fluxheim-owned in the 1.5.6 line, including the internal async IO boundary
used by accepted TCP connections and selected upstream connections. Stream
upstream TLS also uses Fluxheim-owned tokio-rustls / tokio-openssl
connectors in 1.5.6; it no longer depends on Pingora's stream wrapper or TLS
connector adapter.
[stream]
enabled = true
[[stream.routes]]
name = "postgres"
listen = ["127.0.0.1:15432"]
upstreams = ["10.0.0.11:5432", "10.0.0.12:5432"]
# upstream_weights = [1, 2]
# upstream_aliases = ["pg-a", "pg-b"]
# backup_upstreams = []
# drain_upstreams = []
connect_timeout_secs = 5
idle_timeout_secs = 300
# max_connection_secs = 3600
max_connection_bytes = 1073741824
max_connections = 1024
downstream_proxy_protocol = "off" # "off", "v1", or "v2"
trusted_proxies = []
upstream_proxy_protocol = "off" # "off", "v1", or "v2"
upstream_tls = false
# upstream_sni = "db.internal.example"
# upstream_verify_cert = true
# upstream_verify_hostname = true
# upstream_alternative_cn = "db-alt.internal.example"
# upstream_ca_path = "/etc/fluxheim/upstreams/db-ca.pem"
# upstream_client_cert_path = "/etc/fluxheim/upstreams/client-chain.pem"
# upstream_client_key_path = "/etc/fluxheim/upstreams/client-key.pem"listenentries areip:portTCP listeners. Each listener may appear on only one stream route.- Configure either
upstream = "host:port"orupstreams = ["host:port", ...]. Multiple upstreams use stream-local round-robin selection by default. upstream_weightsoptionally enables weighted stream selection and must have one positive value for eachupstreamsentry.upstream_aliasesoptionally assigns safe low-cardinality names for stream logs and future metrics.backup_upstreamsanddrain_upstreamsare optional subsets ofupstreams. Drained stream upstreams do not receive new connections. Backup stream upstreams are not selected while a primary is available, but are tried as connect-fallback candidates before the downstream stream starts copying.connect_timeout_secsbounds DNS/connect setup and defaults to5.idle_timeout_secsis a true stream idle timer and defaults to300. The timer resets whenever either direction transfers bytes.max_connection_secsis optional and bounds total accepted stream lifetime when set. Leave it unset for no wall-clock lifetime cap.max_connection_bytesis optional and caps copied bytes per direction for a single stream connection.max_connections = 0means unlimited for that stream route. Non-zero values cap concurrent accepted connections before connecting upstream.downstream_proxy_protocolenables PROXY protocol receive for this stream route only. It defaults tooffand requires route-localtrusted_proxies. The HTTPserver.proxy_protocolsetting does not apply to stream listeners.upstream_proxy_protocolwrites a HAProxy PROXY protocol header to the selected upstream before forwarding stream bytes. Use it only when the upstream explicitly expects PROXY protocol. It cannot be combined withupstream_tls; stream TLS handshakes need a dedicated pre-TLS PROXY connector before that combination can be enabled safely.upstream_tls = truesends TLS to the selected stream upstream.upstream_sniis optional; when unset Fluxheim derives SNI from the selected upstream host. IP upstreams do not have a DNS hostname to verify; setupstream_sniwhen a TLS certificate must be matched for an IP-address upstream.upstream_verify_certandupstream_verify_hostnamedefault totrue; disabling certificate verification also requires hostname verification to be disabled so the policy cannot imply a hostname check that is not happening.upstream_alternative_cnreplaces the SNI-derived verification hostname with one explicit non-wildcard hostname. It is not an additional hostname checked alongside SNI.upstream_ca_pathloads a route-local PEM CA bundle.upstream_client_cert_pathandupstream_client_key_pathconfigure upstream mTLS client material and must be set together. Custom trust roots and upstream client certificates are supported for rustls and OpenSSL builds. Fluxheim zeroizes the PEM input buffers after parsing; in rustls builds the parsed private-key DER is then owned by rustls 0.23, which does not yet provide zeroing of private-key DER bytes on drop. This is a known upstream limitation. BoringSSL and s2n are not supported Fluxheim TLS backends.
When metrics is compiled and enabled,
fluxheim_stream_connections_total{route,outcome} records bounded connection
outcomes and fluxheim_stream_bytes_total{route,direction} records copied
bytes in each direction.
[udp] is disabled by default. In 1.5.16 it is a beta UDP/GSLB exploration
runtime, not a production UDP platform. Normal release profiles do not enable
the udp-proxy feature, and udp.enabled = true fails clearly unless
Fluxheim is built with that beta feature.
The namespace is intentionally separate from [stream]; TCP stream routes
remain TCP-only and do not accept UDP listeners.
[udp]
enabled = false
[[udp.routes]]
name = "dns-edge"
mode = "dns-load-balance" # active: dns-load-balance, syslog-forward; reserved: quic-pass-through, game-proxy
listen = ["127.0.0.1:5353"]
upstreams = ["192.0.2.10:53", "192.0.2.11:53"]
# upstream_weights = [1, 1]
# upstream_aliases = ["dns-a", "dns-b"]
idle_timeout_secs = 30
response_timeout_secs = 3
max_datagram_bytes = 1232
max_sessions = 4096modeis an explicit runtime target, not a generic protocol parser.dns-load-balanceperforms one bounded upstream request/response exchange per downstream datagram.syslog-forwardforwards one datagram upstream and does not wait for a response.quic-pass-throughandgame-proxyare reserved until route-local UDP session affinity is implemented.listenentries areip:portUDP listeners. Each listener may appear on only one UDP route.- Configure either
upstream = "host:port"orupstreams = ["host:port", ...].upstream_weightsandupstream_aliasesare valid only withupstreamsand must match its length. idle_timeout_secsis required and non-zero.response_timeout_secsdefaults to3and must be less than or equal toidle_timeout_secs.dns-load-balanceuses it for upstream connect and response waits so unanswered datagrams do not hold route slots for the full idle window.max_datagram_bytesmust be between 1 and 65507. Route examples should use smaller protocol-aware values where possible, such as 1232 bytes for DNS over UDP deployments that want conservative fragmentation behavior.max_sessionsdefaults to4096.max_sessions = 0means unlimited for that UDP route. Non-zero values are capped at 1000000.dns-load-balanceis beta and can act as a UDP reflector if exposed to untrusted networks. Bind beta listeners to loopback or internal interfaces unless the deployment has upstream ingress filtering such as BCP38. Response rate limiting, DNS-specific amplification controls, and GSLB policy are required before this mode is promoted for public DNS-edge use.1.5.16does not add QUIC pass-through, game-server UDP proxying, generic UDP proxying, an authoritative DNS server, WAF, VPN/firewall appliance behavior, HTTP/3 ingress, or Wasm/iRules/Lua scripting.
[admin] is disabled by default. When enabled, it must be authenticated and
loopback-only unless the operator explicitly relaxes that.
[admin]
enabled = false
listen = "127.0.0.1:9090"
require_loopback = true
token_env = "FLUXHEIM_ADMIN_TOKEN"
token_file = "/run/secrets/fluxheim-admin-token"
snapshot_store = "/var/lib/fluxheim/snapshots"
[admin.transport]
mode = "local_only"
[admin.ops_socket]
enabled = false
path = "/run/fluxheim/fluxheim-ops.sock"
mode = "0600"
[admin.health]
unauthenticated = false
response = "status"
[admin.auth_throttle]
enabled = true
window_secs = 60
per_source_failures = 10
global_failures = 100
base_lockout_secs = 30
max_lockout_secs = 900
max_sources = 4096
[admin.client_certificate]
required = false
sha256_header = "x-client-cert-sha256"
allow_sha256 = []
deny_sha256 = []
[admin.self_healing]
enabled = false
validation_window_secs = 30
health_path = "/_fluxheim/health"
min_successful_checks = 1
max_error_rate_per_mille = 100If admin.enabled = true, configure token_env or token_file. Snapshot and
rollback endpoints also require snapshot_store. token_file and
snapshot_store must not contain parent traversal, must not sit below a
symlinked parent directory, and on Unix must not use a group- or world-writable
existing parent such as /tmp. The snapshot store runtime applies the same rule when it
is used directly by CLI/admin paths.
Remote admin exposure fails closed. Keep admin.listen loopback whenever
possible. If admin.require_loopback = false and admin.listen is non-loopback,
Fluxheim requires [admin.transport] mode = "trusted_tls_terminator" to make
the operator explicitly declare that a trusted local sidecar, reverse proxy, or
load balancer terminates TLS/mTLS before traffic reaches the plain admin
listener. Direct first-class admin TLS/mTLS remains planned; do not expose the
admin listener over cleartext networks.
[admin.ops_socket] enables a separate Unix-domain HTTP socket for local,
read-only operational checks. It exposes only GET /_fluxheim/status,
GET /_fluxheim/cache/status, GET /_fluxheim/snapshots, and the configured
admin health path; mutating admin endpoints such as reload, rollback, and cache
purge are not routed on this socket. The socket requires admin.enabled = true,
is Unix-only, and validates its path with the same parent traversal, symlink, and
unsafe-writable-parent checks used for process sockets. mode must grant owner
read/write access, may grant group read/write access, and must not grant world
access; use 0600 for service-owner-only status or 0660 for a dedicated
operator group.
When compiled with load-balancer, GET /_fluxheim/status includes a
load_balancer object for configured vhost and route pools. The status is
read-only and reports backend readiness, aliases, weights, backup/drain/disabled
state, ready and policy-available backend counts, primary/backup availability
counts, drain/disabled/forced-down/ejected/saturated summary counts, runtime
override counts, circuit-open counts, selection policy, max-iteration and
all-down settings, discovery mode (static, file, http, or dns),
discovery refresh status, update frequency, success/failure counters, last
success/failure timestamps, bounded last discovery error, health-check
protocol, frequency and parallel mode, retry policy, passive-health
thresholds, slow-start duration, persistence policy and table size, queue policy
and current waiting count, priority group, locality, tags, max in-flight cap,
current in-flight count, passive failure count, passive ejection, passive
ejection remaining seconds, circuit state, slow-start allowance, persistence
entries currently pinned to each backend, and least-time latency state where
available. In the current 1.5.x line, circuit_state = "open" is the runtime
status view for a backend currently ejected by passive health; "closed" means
the backend is not passively ejected. Per-backend rows include
runtime_state_override when an
authenticated runtime member operation is active and
runtime_state_changed_at_unix_secs when that override currently has a recorded
manual transition time. In privacy-mode, backend addresses are omitted from
this status object.
Background discovery refresh loops also emit
fluxheim_load_balancer_events_total with event = "discovery_success" or
event = "discovery_failure" and the same vhost/route pool labels used by
selection, retry, queue, and runtime mutation events.
When compiled with load-balancer, authenticated admins can update the
in-memory state of an existing configured pool member without reloading:
curl -X POST \
-H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
"http://127.0.0.1:8081/_fluxheim/load-balancer/member-state?vhost=app&member=app-a&state=drain"Use vhost for the vhost name, optional route for a route-local pool,
member for the configured upstream address or upstream_aliases value, and
state as normal, drain, disable, forced_down, or manual_resume.
forced_down removes the member from selection like disable but remains
distinct in admin status so operators can separate forced health actions from
maintenance disables. manual_resume clears any runtime override, clears the
member's passive-health failure/ejection state, and restarts slow-start ramp
when slow-start is configured. normal clears only runtime overrides; static
drain_upstreams and disabled_upstreams remain enforced until the config
changes. Runtime member state is in-memory unless
proxy.load_balance.runtime_state_file is configured for that pool. Mutation
responses include "persistent": true when the pool has a local runtime state
file and "persistent": false otherwise. The response also includes
scope = "vhost" or "route" so operators can audit which pool was changed.
In privacy-mode, member mutation responses and structured mutation logs omit
backend addresses just like status output. Member fields use configured aliases
when present and redacted otherwise. Successful and rejected mutation metrics
keep member attribution in normal builds and use configured aliases only in
privacy-mode.
For dynamic DNS/file/HTTP-discovery pools, Fluxheim may reclaim stale runtime
drain overrides after a member disappears from the live discovery set.
Runtime disable and forced_down overrides are retained across discovery
churn and are cleared only by explicit normal or manual_resume admin action.
The retained runtime override table is bounded; if the table is full, new
runtime state or weight overrides fail with a bounded admin error instead of
growing memory without limit.
Successful and rejected member-state operations are logged under the
fluxheim::load_balancer target and, when metrics are compiled, counted by
fluxheim_load_balancer_events_total with bounded events member_state,
member_state_invalid, and member_state_not_found.
Authenticated admins can also mutate the runtime backend set for static upstream pools:
curl -X POST \
-H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
"http://127.0.0.1:8081/_fluxheim/load-balancer/member-add?vhost=app&member=127.0.0.1:3002&weight=2"
curl -X POST \
-H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
"http://127.0.0.1:8081/_fluxheim/load-balancer/member-update?vhost=app&member=127.0.0.1:3002&weight=5"
curl -X POST \
-H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
"http://127.0.0.1:8081/_fluxheim/load-balancer/member-remove?vhost=app&member=127.0.0.1:3002"member-add takes a socket-address member and optional numeric weight
between 1 and 1000 (default 1). member-update takes the existing
member, optional weight, and optional replacement address via address,
new_member, X-Fluxheim-Lb-Address, or X-Fluxheim-Lb-New-Member.
Address retargeting is rejected for aliased members because aliases are part of
the static config identity; change those through config reload. member-remove
removes an existing configured address or alias. The backend set and health map
are published as one atomic runtime snapshot, so status and selection do not
observe a half-updated pool. Remove and address-update operations reject members
with active in-flight connections; drain the member first, wait for it to reach
zero in-flight requests, then remove or retarget it. The in-flight check is a
best-effort ordering gate at mutation time; a very narrow race can still allow
one already-selected request to complete against the old member after removal
or retarget, and Fluxheim emits a load-balancer warning if that is observed.
Runtime backend sets are capped at 256 members in this release; remove a member
before adding another when the cap is reached.
Runtime backend-set mutation is available only for static upstream pools in
this release. It is rejected for DNS/file/HTTP-discovery pools because
discovery refresh would overwrite local admin changes. It is also rejected for
Maglev selectors because Maglev requires a rebuilt lookup table; use a
non-Maglev selector for runtime backend-set changes. Runtime-added or retargeted members
carry only address and configured weight; aliases, tags, backup membership,
priority groups, locality metadata, and per-upstream caps still come from the
static config and require reload. Backend-set additions, removals, and
configured-weight updates are in-memory control-plane actions and are reported
with "persistent": false; proxy.load_balance.runtime_state_file currently
persists runtime member-state overrides, runtime weight overrides, and local
persistence tables, not the mutated backend set itself.
Authenticated admins can adjust the runtime weight of an already configured
member without reloading when the pool uses round-robin, least-connections,
least-sessions, or least-time selection:
curl -X POST \
-H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
"http://127.0.0.1:8081/_fluxheim/load-balancer/member-weight?vhost=app&member=app-a&weight=25"Use weight = "default", reset, clear, or configured to remove the
runtime override and return to the configured upstream_weights value. Runtime
weights are bounded to 1..=1000, are local/in-memory like runtime member
state, and are returned in backend status as effective_weight,
runtime_weight_override, and runtime_weight_changed_at_unix_secs.
For dynamic DNS/file/HTTP-discovery pools, runtime weight overrides are retained
while the same backend is explicitly disabled or forced_down, and are
otherwise reclaimed after the backend leaves the live discovery set.
Successful and rejected weight operations are counted as member_weight,
member_weight_invalid, and member_weight_not_found.
Authenticated admins can fetch only load-balancer runtime state without parsing
the full /_fluxheim/status payload:
curl -H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
"http://127.0.0.1:8081/_fluxheim/load-balancer/status"The response is {"status":"ok","load_balancer":...} and uses the same
runtime pool schema embedded in /_fluxheim/status: vhost pools, route pools,
backend health, runtime member-state overrides, queue depth, persistence table
size, circuit/passive-health state, slow-start state, locality, tags, aliases,
and in-flight counts. When admin.ops_socket.enabled = true, the same read-only
endpoint is available over the local Unix ops socket without bearer-token
authentication.
Authenticated admins can clear the local persistence table for one configured vhost or route pool without reloading:
curl -X POST \
-H "Authorization: Bearer $FLUXHEIM_ADMIN_TOKEN" \
"http://127.0.0.1:8081/_fluxheim/load-balancer/persistence/clear?vhost=app"Use the optional route query parameter or X-Fluxheim-Lb-Route header to
target a route-local pool. The response includes cleared_entries,
scope = "vhost" or "route", and a persistent boolean. The operation is
local to the current runtime unless proxy.load_balance.runtime_state_file is
configured for that pool, in which case the cleared table is written back to
the local runtime state file; it does not alter config or durable snapshots.
When metrics are compiled, successful clears are counted as
persistence_clear in fluxheim_load_balancer_events_total; rejected clear
requests are counted separately as persistence_clear_invalid or
persistence_clear_not_found.
admin.client_certificate is an extra hardening gate for that trusted
terminator pattern. The admin listener still receives plain HTTP from the
trusted local sidecar, but Fluxheim can require a validated downstream client
certificate fingerprint that the sidecar injects into sha256_header.
required = true rejects requests without a single valid 64-character SHA-256
hex value, deny_sha256 rejects matching fingerprints, and allow_sha256
restricts admin access to the listed fingerprints when it is non-empty. The
trusted terminator must strip any incoming copy of this header before adding its
own value; do not rely on this option for directly exposed cleartext admin
listeners.
When this option is enabled on a loopback admin listener, Fluxheim emits a
security warning because local processes can inject the configured header unless
a trusted local terminator strips and rewrites it.
Admin endpoint paths are capped at 2048 bytes and query strings are capped at 16 KiB before endpoint-specific parsing. Prefer headers for long cache purge values.
admin.auth_throttle is enabled by default and protects all authenticated
/_fluxheim/* endpoints, including the built-in health check unless it is
explicitly configured for loopback-only unauthenticated probes. Repeated failed
bearer-token attempts are tracked per direct socket source and globally over
window_secs; once either limit is reached, Fluxheim returns 429 until the
progressive lockout expires. max_sources bounds the in-memory per-source
failure table. With metrics enabled,
fluxheim_admin_auth_events_total{event,scope} records failed and throttled
admin authentication events, and security logs are emitted without reflecting
the attempted token.
The protected cache purge endpoints accept the optional vhost and route
query parameters, or x-fluxheim-cache-vhost and
x-fluxheim-cache-route headers, to target either a vhost cache policy or a
named route-scoped cache policy. Route names are resolved within the selected
vhost. Purge responses include the selected vhost/route, the normalized
host, method, path, optional query, cache key, and per-tier purge result
so bulk purge output can be audited without decoding cache keys.
Indexed scope, prefix, tag, and wildcard purge endpoints accept soft=true or
x-fluxheim-cache-soft: true to mark matched objects stale without deleting
their cached bodies. Hard purge remains the default.
admin.self_healing.health_path must be an absolute path no longer than 2048
bytes and cannot contain whitespace, control characters, backslashes, ?, or
#. Custom health paths must not use the protected /_fluxheim/ admin prefix.
The built-in /_fluxheim/health endpoint requires bearer-token authentication
by default. Set [admin.health] unauthenticated = true only for loopback-bound
local probes; validation rejects unauthenticated health on non-loopback admin
listeners. admin.health.response = "minimal" returns an empty 204 instead
of the default JSON status body to reduce fingerprinting.
Snapshot messages submitted through the admin API are trimmed and capped at 4096 bytes of non-control text before they are persisted.
On Linux, token_file is opened without following symlinks, must resolve to a
regular file handle, must not sit below a symlinked or group- or world-writable parent
directory, and is capped at 8 KiB both before and during the read. Prefer
rootless container secrets or a local file readable only by the Fluxheim user.
[metrics] is disabled by default and should remain loopback-only unless it is
fronted by a trusted local monitoring agent.
[metrics]
enabled = false
listen = "127.0.0.1:9091"
require_loopback = true
[metrics.otlp]
enabled = false
endpoint = "http://127.0.0.1:9090/api/v1/otlp/v1/metrics"
service_name = "fluxheim"
interval_secs = 15
timeout_secs = 2
# Optional PEM CA bundle for private-PKI HTTPS collectors.
# tls_ca_cert_path = "/etc/fluxheim/otlp-ca.pem"The metrics compile-time feature is not part of profile-privacy.
metrics.otlp.enabled = true requires the metrics-otlp feature. The exporter
sends OTLP/HTTP JSON to http:// or https:// endpoints. Prefer local
loopback HTTP for same-host collectors and HTTPS for remote collectors.
metrics.otlp.tls_ca_cert_path can point at a PEM CA bundle for private PKI
collectors; when omitted, the bundled WebPKI roots are used. Plaintext HTTP to
non-loopback collectors logs a warning. When enabled,
fluxheim_metrics_otlp_exports_total{outcome} records bounded exporter success
and failure attempts through the local Prometheus metrics surface.
[tracing] is disabled by default and requires a build with the
otel-tracing feature, or a profile that includes it such as
profile-observability.
[tracing]
enabled = false
mode = "propagate_only"
traceparent = true
log_trace_id = true
[tracing.otlp]
enabled = false
endpoint = "http://127.0.0.1:4318/v1/traces"
service_name = "fluxheim"
queue_size = 8192
timeout_secs = 2
# Optional PEM CA bundle for private-PKI HTTPS collectors.
# tls_ca_cert_path = "/etc/fluxheim/otlp-ca.pem"Implemented values:
mode = "propagate_only"validates W3Ctraceparent, generates a trace context when needed, and forwards a normalizedtraceparentto upstreams.traceparent = trueenables inbound/outbound W3C Trace Context propagation.log_trace_id = trueaddstrace_idto structured access logs when tracing is enabled.
tracing.enabled = true is rejected when Fluxheim is built without
otel-tracing. tracing.otlp.enabled = true requires the otel-otlp feature.
The exporter supports OTLP/HTTP JSON over http:// or https://.
tracing.otlp.tls_ca_cert_path can point at a PEM CA bundle for private PKI
collectors; when omitted, the bundled WebPKI roots are used. Prefer loopback
HTTP for local collectors and HTTPS for remote collectors; plaintext HTTP to a
non-loopback collector logs a warning. When Fluxheim is built with the cache
feature, exported request spans include bounded cache attributes for the cache
phase plus cache lookup and request-collapsing wait durations. They do not
include cache keys, paths beyond the normal HTTP span name, query strings,
cookies, or request header values. otel-tracing and otel-otlp are
incompatible with privacy-mode.
Load-balanced spans may include fluxheim.load_balancer.upstream from the
configured proxy.upstream_aliases value and
fluxheim.load_balancer.retries; raw upstream URLs are not exported as trace
attributes.
[logging]
level = "info"
format = "json"
target = "stderr"
[logging.file]
enabled = false
# path = "/var/log/fluxheim/fluxheim.log"
append = true
[logging.access]
enabled = true
include_host = true
include_path = true
request_id = true
request_id_header = "x-request-id"level values: error, warn, info, debug, trace.
format values: json, text.
target values: stderr, stdout. File logging overrides this stream target
when logging.file.enabled = true.
logging.file is disabled by default. When enabled, path is required. Relative
paths are resolved from the config file that defines them. Existing symlinked
path prefixes are rejected during config validation, and Linux opens the log file
without following a final symlink. On Unix, file logs must use a dedicated log
directory and are rejected when the nearest existing parent is group- or world-writable,
such as /tmp.
In privacy-mode builds, access logging and file logging must stay disabled.
Fluxheim rejects logging.access.enabled = true and
logging.file.enabled = true.
logging.access.include_path = false keeps access logging enabled while
emitting an empty path field. This is useful when request paths may contain
tenant IDs, filenames, or other sensitive identifiers.
logging.access.include_host = false keeps access logging enabled while
emitting an empty raw host field. The configured vhost name is still logged
after Fluxheim resolves the request.
logging.access.include_client_ip = false emits an empty client_ip field.
When enabled, the logged address is the same trusted-proxy-aware client IP used
by access policy and rate limiting.
logging.access.include_cache_phase = false emits an empty cache_phase field.
When enabled in cache-capable builds, Fluxheim logs the effective request cache
phase, such as hit, miss, bypass, or disabled.
Access log events include compression_encoding when Fluxheim applies response
compression; the field is empty when the response is served without a Fluxheim
compression encoder.
Access log events include downstream TLS identity fields:
tls_version, tls_cipher, tls_client_cert_sha256,
tls_client_cert_serial, and tls_client_cert_organization. These fields are
empty when the request is not TLS, when no client certificate was presented, or
when the selected TLS backend does not expose a given certificate attribute.
Access log events also include the resolved route name and selected upstream
address when a request reaches a proxy action; fallback, local static, or
unrouted requests emit empty route or upstream fields as applicable.
Load-balanced requests also include upstream_alias when configured through
proxy.upstream_aliases, plus upstream_retries for the number of retry
attempts Fluxheim made after the first selected upstream.
logging.access.include_route = false emits an empty route field.
logging.access.include_upstream = false emits empty upstream and
upstream_alias fields, which is useful when internal backend addresses or
operator-defined backend labels should not appear in logs.
Header policies can be global or per-vhost. Vhost policies overlay the global policy.
[headers.request]
enabled = true
strip_inbound_client_ip_headers = true
x_forwarded_for = "replace"
x_real_ip = true
x_forwarded_host = true
x_forwarded_proto = true
forwarded = false
remove = ["x-powered-by"]
[headers.request.add]
x-proxy-by = "Fluxheim"
x-real-ip = "{remote_addr}"
x-forwarded-host = "{host}"
x-forwarded-proto = "{scheme}"
[headers.request.append]
via = "fluxheim"
[headers.response]
enabled = true
x_content_type_options = "nosniff"
x_frame_options = "DENY"
referrer_policy = "no-referrer"
remove = ["x-powered-by"]
[headers.response.add]
cache-control = "public, max-age=60"
[headers.response.append]
vary = ["Accept-Encoding"]
[headers.response.operations]
remove = ["x-origin-banner"]
add = { x-content-source = "fluxheim" }
[[headers.response.rewrite.location]]
from = "http://backend.internal/"
to = "https://www.example.com/"
[[headers.response.rewrite.refresh]]
from = "http://backend.internal/"
to = "https://www.example.com/"
[[headers.response.rewrite.cookie_domain]]
from = "backend.internal"
to = "www.example.com"
[[headers.response.rewrite.cookie_path]]
from = "/app/"
to = "/"x_forwarded_for values: off, replace, append. x_real_ip = true
emits X-Real-IP from the effective client address. If the direct peer matches
server.trusted_proxies, Fluxheim recursively restores that address from the
trusted X-Forwarded-For chain before writing X-Real-IP, X-Forwarded-For,
Forwarded, or {remote_addr} templates. In privacy builds it defaults off and
client-IP forwarding remains stripped.
Request header values can use a small safe dynamic template set:
{host}: original requestHostheader.{remote_addr}: observed client IP address.{scheme}:httporhttpsfrom the downstream listener.{uri}: current request path and query.{path}: current request path.{query}: current request query without?, or empty.{request_id}: Fluxheim request ID when access request IDs are enabled.{tls.cipher}and{tls.version}: downstream TLS cipher and protocol when the request arrived over TLS.{tls.client_cert_sha256}: lowercase SHA-256 fingerprint of the verified downstream client certificate when one was presented.{tls.client_cert_serial}and{tls.client_cert_organization}: serial number and organization parsed from the verified downstream client certificate when the TLS backend exposes them.{http.<header-name>}: safe request-header forwarding, for example{http.upgrade}.
Unknown variables fail config validation. Rendered values are still passed
through HTTP header validation before Fluxheim sends them upstream.
Do not put TLS identity templates in [headers.request.append] or
[vhosts.headers.request.append]; Fluxheim rejects that configuration because
an attacker-supplied inbound header would otherwise remain before the
TLS-derived value. Use add/set for TLS identity headers so Fluxheim removes
any inbound copy before forwarding the trusted value.
Common proxy migration headers:
[headers.request.add]
host = "{host}"
x-real-ip = "{remote_addr}"
x-forwarded-for = "{remote_addr}"
x-forwarded-proto = "{scheme}"
x-forwarded-host = "{host}"
x-client-cert-sha256 = "{tls.client_cert_sha256}"
upgrade = "{http.upgrade}"
connection = "upgrade"Prefer the typed x_forwarded_for, x_real_ip, x_forwarded_host, and
x_forwarded_proto fields where they fit. Use dynamic values when a backend
expects an exact legacy-style header.
For header mutations, remove/add are the preferred readable names.
unset/set remain supported for compatibility. The nested
[headers.request.operations], [headers.response.operations], and
[vhosts.headers.*.operations] tables are useful when you want all explicit
header operations grouped together. Do not define the same header in more than
one set, add, or operations.add table in the same policy; Fluxheim rejects
that as ambiguous. Each header mutation policy is bounded: remove/unset, set/add,
and append header-name collections are capped at 128 entries each, and a single
append header may contain at most 32 values.
Security headers are easy to enable globally:
[headers.response]
content_security_policy = "default-src 'self'"
x_content_type_options = "nosniff"
x_frame_options = "DENY"
referrer_policy = "no-referrer"
[headers.response.hsts]
enabled = true
max_age_secs = 63072000
include_subdomains = false
preload = falseYou may still set headers.response.strict_transport_security directly as a raw
header value, but do not combine it with [headers.response.hsts] in the same
policy. HSTS and CSP are intentionally not enabled blindly in examples because
they are site-specific and can break local HTTP testing or asset policies.
Fluxheim sets Server: fluxheim and strips X-Powered-By by default. Operators
who do not want a server banner can remove it with remove = ["server"], and
operators who want a different banner can set one through
[headers.response.add].
[[headers.response.rewrite.location]] and
[[headers.response.rewrite.refresh]] provide bounded prefix rewrites for
upstream redirect headers, similar to NGINX proxy_redirect or Apache
ProxyPassReverse. Location rewrites apply to the whole header value.
Refresh rewrites apply only to the URL after url= and preserve the refresh
delay. from and to values must be absolute http:// / https:// prefixes
or absolute paths, must not contain control characters, and each header supports
up to 32 rules.
[[headers.response.rewrite.cookie_domain]] and
[[headers.response.rewrite.cookie_path]] rewrite Set-Cookie Domain= and
Path= attributes. Domain rewrites are exact case-insensitive matches against
safe ASCII domain labels. Path rewrites are prefix replacements and must use
absolute paths. Vhost and route response-header policies inherit global rewrite
rules and can append additional rules.
[proxy] is the global fallback proxy policy. Vhosts can override it with
[vhosts.proxy].
[proxy]
upstreams = ["127.0.0.1:3000", "127.0.0.1:3001"]
upstream_weights = [1, 2]
upstream_priority_groups = [100, 50]
upstream_priority_group_min_active = 1
upstream_localities = ["site-a", "site-b"]
preferred_upstream_localities = ["site-a"]
upstream_max_in_flight = [256, 512]
upstream_aliases = ["app-a", "app-b"]
upstream_tags = [["blue", "primary"], ["green"]]
backup_upstreams = ["127.0.0.1:3001"]
drain_upstreams = []
disabled_upstreams = []
upstream_tls = false
upstream_sni = "origin.example.test"
upstream_verify_cert = true
upstream_verify_hostname = true
upstream_alternative_cn = "fallback-origin.example.test"
upstream_ca_path = "/etc/fluxheim/upstreams/origin-ca.pem"
upstream_client_cert_path = "/etc/fluxheim/upstreams/client-chain.pem"
upstream_client_key_path = "/etc/fluxheim/upstreams/client-key.pem"
upstream_proxy_protocol = "off"
upstream_http_version = "http1"
websocket = false
[proxy.auth_request]
enabled = false
# url = "http://127.0.0.1:4180/auth"
forward_headers = ["authorization", "cookie"]
allow_response_headers = ["x-auth-request-user", "x-auth-request-email"]
connect_timeout_secs = 2
read_timeout_secs = 5
max_response_bytes = "64KiB"
[proxy.mirror]
enabled = false
# base_url = "http://127.0.0.1:9000/shadow"
sample_per_mille = 1000
methods = ["GET", "HEAD", "OPTIONS"]
forward_headers = ["user-agent"]
timeout_secs = 2
max_response_bytes = "16KiB"
max_in_flight = 64
# upstream_h2_max_streams = 64
# upstream_h2_ping_interval_secs = 30
connect_timeout_secs = 5
upstream_total_connection_timeout_secs = 10
upstream_idle_timeout_secs = 120
upstream_tcp_keepalive_idle_secs = 30
upstream_tcp_keepalive_interval_secs = 10
upstream_tcp_keepalive_count = 3
upstream_tcp_user_timeout_ms = 15000
upstream_tcp_recv_buffer_bytes = "1MiB"
upstream_dscp = 46
upstream_tcp_fast_open = false
read_timeout_secs = 60
send_timeout_secs = 30
downstream_write_timeout_secs = 30
downstream_min_send_rate_bytes_per_sec = 8192
[proxy.load_balance]
selection = "round-robin"
max_iterations = 256
all_down_status = 502
# Optional local restart-persistent runtime state file for load-balancer member
# overrides and local persistence tables.
# runtime_state_file = "/var/lib/fluxheim/load-balancer/default.json"
[proxy.load_balance.health_check]
enabled = true
protocol = "tcp"
interval_secs = 1
consecutive_success = 1
consecutive_failure = 1
parallel = false
method = "GET"
path = "/"
host = "origin.example.test"
expected_statuses = []
expected_status_ranges = []
expected_headers = []
expected_body_contains = []
reuse_connection = false
connect_timeout_secs = 1
read_timeout_secs = 1
[proxy.load_balance.slow_start]
enabled = false
duration_secs = 30
[proxy.load_balance.passive_health]
enabled = false
consecutive_failure = 3
ejection_secs = 30
failure_statuses = []
failure_status_ranges = []
max_latency_ms = 0
[proxy.load_balance.retry]
enabled = false
max_retries = 1
methods = ["GET", "HEAD", "OPTIONS"]
statuses = []
status_ranges = []
budget_per_window = 0
budget_window_secs = 1
[proxy.load_balance.queue]
max_waiting = 0
timeout_ms = 0
retry_interval_ms = 10
[proxy.load_balance.persistence]
enabled = false
mode = "source-ip"
ttl_secs = 300
table_max_entries = 65536
[[proxy.error_pages]]
status = 502
path = "/502.html"
[proxy.error_pages.web]
root = "/srv/fluxheim/errors"
cache_control = "private, no-store"Every upstreams entry must be an authority such as
127.0.0.1:3000 or origin.example.test:443.
Proxy upstream lists are capped at 64 entries and reject duplicates
case-insensitively. Proxy error-page lists are also capped at 64 entries.
For load-balancer builds, upstreams_file = "/run/fluxheim/backends/app.txt"
can be used instead of upstream or upstreams. The file is read at startup
and refreshed every upstreams_file_refresh_secs seconds, with a default of
5 seconds and a bounded range of 1 through 300 seconds. The first file format is
deliberately small: one host:port or ip:port authority per line, blank lines
and full-line # comments ignored, 2 through 64 unique entries required.
Fluxheim reads the file with the same symlink and parent-permission hardening
used for other operator-controlled files. In this release, file-refreshed pools
cannot be combined with upstream_weights, upstream_priority_groups,
upstream_localities, preferred_upstream_localities,
upstream_max_in_flight, upstream_aliases, upstream_tags, backup_upstreams,
drain_upstreams, or disabled_upstreams; use static upstreams for those
policies.
For DNS-based service names, load-balancer builds can set
upstream_dns_refresh_secs = 5 together with upstreams = ["app.service:8080"].
Fluxheim resolves those authorities at startup and then refreshes them on the
configured 1 through 300 second interval. This first DNS-refresh slice is
mutually exclusive with upstream, upstreams_file, upstream_weights,
upstream_priority_groups, upstream_localities,
preferred_upstream_localities, upstream_max_in_flight, upstream_aliases,
upstream_tags, backup_upstreams, drain_upstreams, and
disabled_upstreams; use the
static pool form when those richer backend policies are required.
For pull-based control-plane discovery, load-balancer builds can set
upstreams_http_url = "https://control-plane.example.test/v1/upstreams" instead
of upstream, upstreams, or upstreams_file. Fluxheim fetches the endpoint at
startup and refreshes it every upstreams_http_refresh_secs seconds, with a
default of 5 seconds and a bounded range of 1 through 300 seconds. The response
body is bounded to 64 KiB and must be JSON in either of these forms:
["10.0.0.10:8080","10.0.0.11:8080"] or
{"upstreams":["10.0.0.10:8080","10.0.0.11:8080"]}. The parsed upstream list
must contain 2 through 64 unique host:port or ip:port authorities. The
optional upstreams_http_bearer_token_file adds a Bearer token to the request;
the token file is validated with the same safe-path and parent-permission checks
used for other operator-controlled secret files, must not be empty, and must not
contain whitespace after trimming surrounding whitespace. Fluxheim sends
Accept: application/json and Cache-Control: no-store, and rejects non-JSON
Content-Type values when the discovery endpoint includes that header; missing
Content-Type is accepted so small internal sidecars can stay simple.
Discovery endpoints must use HTTPS unless they are numeric loopback
http://127.0.0.1 or http://[::1] control-plane sidecars. HTTP discovery is
intentionally pull-only in this
release: it does not watch Kubernetes, Consul, or xDS streams directly, and it
cannot be combined with per-member static policy lists such as weights,
localities, aliases, tags, backup, drain, disabled, or max-in-flight.
examples/load-balancer-http-discovery.toml contains a complete minimal
control-plane-backed load-balancer pool, including health checks, passive
health, retries, and queue policy.
Changing a load-balanced pool's discovery source, discovery refresh interval,
HTTP bearer-token file, or route/vhost pool membership is classified as a
process-upgrade change rather than a live snapshot reload because the refresh
loop is registered with the process service set at startup.
When upstream_tls = true, Fluxheim sends TLS to the origin. upstream_sni
overrides the SNI name; if it is omitted, Fluxheim derives SNI from the primary
upstream host. upstream_verify_cert and upstream_verify_hostname default to
true. Disabling certificate verification is an explicit insecure policy and
also requires upstream_verify_hostname = false so the config cannot imply
hostname validation while certificate validation is off. upstream_alternative_cn
adds one additional hostname that may match the upstream certificate when the
configured SNI does not. Wildcards are rejected for this field.
upstream_ca_path points at a PEM CA bundle used instead of the platform trust
store for this proxy policy. upstream_client_cert_path and
upstream_client_key_path configure an upstream client certificate and private
key for origin mTLS and must be set together. These file paths are resolved
relative to the containing config file, reject parent-directory traversal,
reject symlinked existing path components, and reject group/world-writable
existing parents. Rustls and OpenSSL builds support custom upstream trust roots
and upstream client certificates. BoringSSL and s2n are not supported Fluxheim
TLS backends.
upstream_proxy_protocol defaults to off. Set it to v1 or v2 to send a
HAProxy PROXY protocol header to the origin immediately after the upstream TCP/Unix
connection is established and before any upstream TLS handshake. The source
address is trusted-proxy-aware: if the direct peer is trusted and
X-Forwarded-For restores a client IP, that restored IP is used with source
port 0; otherwise the direct downstream socket address is used. If Fluxheim
cannot produce a same-family TCP4/TCP6 source and destination pair, it sends
PROXY UNKNOWN for v1 or an empty v2 PROXY/UNSPEC frame for v2.
upstream_http_version defaults to http1. Set it to http2 for origins
that require HTTP/2, including gRPC-style upstreams, or to http1-and-http2
to allow HTTP/2 with HTTP/1.1 fallback where the selected TLS/backend connector
can negotiate it. For plaintext origins, http2 means h2c; use it only when
the origin is known to accept cleartext HTTP/2. upstream_h2_max_streams
limits concurrent streams per upstream HTTP/2 connection and must be between
1 and 1024. upstream_h2_ping_interval_secs enables upstream HTTP/2 keepalive
pings. Both h2 settings require upstream_http_version to allow HTTP/2.
For explicit gRPC routes, set route-scoped [vhosts.routes.grpc] enabled = true; Fluxheim then requires the route proxy to allow upstream HTTP/2, rejects
non-POST requests, accepts only application/grpc or application/grpc+*
content types, and leaves gRPC-Web/JSON transcoding out of scope.
connect_timeout_secs bounds the low-level socket connect phase.
upstream_total_connection_timeout_secs wraps full upstream establishment,
including protocol/TLS setup where the selected connector exposes it.
upstream_idle_timeout_secs controls how long reusable idle upstream
connections remain in Pingora's keepalive pool before they are closed.
upstream_tcp_keepalive_idle_secs, upstream_tcp_keepalive_interval_secs, and
upstream_tcp_keepalive_count configure TCP keepalive probes on upstream
connections and must be set together. upstream_tcp_user_timeout_ms maps to
Linux TCP_USER_TIMEOUT through the same keepalive setting and is ignored by
non-Linux kernels. upstream_tcp_recv_buffer_bytes requests a receive-buffer
size for new upstream sockets, capped at 256MiB. upstream_dscp accepts a DSCP
value from 0 through 63. upstream_tcp_fast_open enables upstream TCP Fast Open
where the platform and kernel allow it.
upstream_weights is optional and, when set, must contain one positive weight
for each upstreams entry. It enables weighted selection in load-balancer
builds. Each weight must be at most 1000 and the total configured weight must
fit in Pingora's weighted selector.
upstream_priority_groups is optional and, when set, must contain one priority
value for each upstreams entry. Higher values are preferred first, then lower
values are activated when higher priority groups have fewer than
upstream_priority_group_min_active selectable members. The activation
threshold defaults to 1, matching strict preferred/fallback behavior. Each
priority group must be at most 1000. This is the static F5-style
preferred/fallback group foundation for the 1.5 load-balancer line.
upstream_localities is optional and, when set, must contain one safe
low-cardinality locality or failure-domain label for each upstreams entry.
preferred_upstream_localities is optional and must refer only to labels from
upstream_localities. When preferred localities are configured, selection tries
matching backends first and then falls back to all localities if no preferred
backend is selectable. This keeps same-site traffic preferred without turning a
site outage into a route outage. Locality labels are normalized
case-insensitively, capped at 64 bytes, and exposed in load-balancer runtime
status.
upstream_max_in_flight is optional and, when set, must contain one positive
concurrency cap for each upstreams entry. A capped backend is skipped when it
already has that many in-flight requests, regardless of the selected
load-balancing algorithm. Each cap must be at most 1000000.
upstream_aliases is optional and, when set, must contain one safe
low-cardinality alias for each upstreams entry. Aliases may contain ASCII
letters, digits, dots, dashes, and underscores, are capped at 64 bytes, and
must be unique case-insensitively. Fluxheim uses them only for operator-facing
metrics and status surfaces; they are not sent upstream and do not affect
selection.
upstream_tags is optional and, when set, must contain one tag list for each
upstreams entry. Tags use the same safe low-cardinality label syntax as
aliases, are capped at 16 tags per backend, must be unique per backend
case-insensitively, and are exposed only in load-balancer runtime status for
operator grouping and migration metadata. They are not sent upstream and do not
affect selection.
backup_upstreams, drain_upstreams, and disabled_upstreams are optional
subsets of upstreams. Backups stay out of normal rotation and are selected
only when no non-backup backend is currently selectable. Drained upstreams
remain configured for health and operator visibility but receive no new
selections. Disabled upstreams are the explicit administrative off state for a
configured member, are reported separately in load-balancer status, and show as
not ready in that status. Backup, drain, and disabled sets must not overlap,
and at least one upstream must remain a normal primary.
proxy.load_balance.selection defaults to round-robin. It also accepts
least-connections, weighted-least-connections,
ratio-least-connections, least-sessions, least-time, power-of-two,
source-hash, uri-hash, header-hash, cookie-hash, consistent-source-hash,
consistent-uri-hash, consistent-header-hash, and
consistent-cookie-hash, bounded-load consistent modes
bounded-load-consistent-source-hash,
bounded-load-consistent-uri-hash,
bounded-load-consistent-header-hash, and
bounded-load-consistent-cookie-hash, plus static-pool Maglev modes
maglev / maglev-source-hash, maglev-uri-hash,
maglev-header-hash, and maglev-cookie-hash. Header-hash modes require
proxy.load_balance.hash_header = "x-session" or another valid HTTP header
name. Cookie-hash modes require proxy.load_balance.hash_cookie = "session" or
another valid cookie name. Hash modes use weighted FNV selection seeded with a
per-boot secret. Consistent modes use Fluxheim-owned weighted rendezvous
hashing, also seeded with a per-boot secret, for low remapping when upstream
membership changes. Upgrading from the pre-1.5.7 Pingora ring-backed
consistent selector can remap existing affinity keys once. Bounded-load
consistent modes use the same rendezvous candidate ordering and skip a hash
target whose weighted in-flight pressure is above the configured soft bound
when another eligible candidate is available inside max_iterations; they
fall back to normal consistent selection if no bounded candidate is found.
bounded_load_factor_per_mille defaults to 1250, meaning roughly 125% of
current weighted average load, and is valid only with bounded-load consistent
selectors. Maglev modes use a fixed 65,537-slot bounded lookup table for static
proxy.upstreams pools only; file-refreshed and DNS-refreshed pools reject
Maglev until dynamic table rebuild semantics are promoted later.
max_iterations bounds how many ready candidates Pingora or Fluxheim may
inspect while applying health, drain, slow-start, backup, priority, and
in-flight policies. all_down_status defaults to 502 and may be set to
another HTTP 5xx status, commonly 503, for requests where a configured
load-balanced pool has no selectable backend. least-connections,
weighted-least-connections, and
ratio-least-connections all use the same Fluxheim-held in-flight request
permits, upstream_weights, and Pingora's current backend health state, so a
backend with weight 4 can carry roughly four times the in-flight request
share of a backend with weight 1. least-sessions requires
proxy.load_balance.persistence.enabled = true and selects by the lowest
bounded persistence-entry share per backend, weighted by upstream_weights.
least-time uses the same request permits plus an EWMA of observed upstream
latency from completed requests, weighted by upstream_weights; unsampled
healthy backends are allowed to receive traffic so new or recovered pool
members can establish a latency baseline. Runtime weight overrides through the
admin API are honored by round-robin, least-connections, least-sessions,
and least-time in the current release. Hash, consistent hash, bounded-load
consistent hash, Maglev, and power-of-two selections reject runtime weight
changes until ring/table rebuild and sampling semantics are specified.
power-of-two
also accepts power-of-two-choices, two-choice, weighted-two-choice, and
weighted-random-two-choice; all names sample two healthy backends through
Pingora's random weighted selector and choose the lower weighted in-flight
pressure using upstream_weights.
With metrics enabled, load-balanced selections, unavailable pools, retries,
queue wait/full/timeout outcomes, and success/failure/ejection outcomes are counted by
fluxheim_load_balancer_events_total with bounded configured vhost/route
labels. The metric does not label raw upstream addresses; it uses
upstream_aliases when present and otherwise leaves the upstream label empty.
fluxheim_load_balancer_pools reports configured pool counts by bounded scope
(vhost or route) and bounded selection algorithm so dashboards can see which
load-balancing modes are active without labeling raw upstreams or route names.
Queued requests that actually wait also record
fluxheim_load_balancer_queue_wait_seconds by configured vhost/route and
bounded outcome (waited or timeout).
When persistence is enabled, the same counter records bounded
persistence_hit, persistence_miss, and persistence_fallback events.
examples/load-balancer-enterprise.toml is the validated 1.5 migration
fixture for a richer HAProxy/F5-style pool: weighted members, aliases, priority
groups, backup/drain policy, active and passive health, slow start,
source-IP persistence, retry budgets, metrics, and explicit all-down behavior.
examples/load-balancer-exec-health.toml shows the local exec health-check
shape for operators that need a bounded command monitor instead of a network
probe.
examples/load-balancer-redis-health.toml shows a Redis PING health-check
shape for Redis pools that expose no HTTP/gRPC health endpoint.
examples/load-balancer-mysql-health.toml shows a MySQL/MariaDB handshake
health-check shape for database pools where a TCP connect is too weak but
authentication or SQL execution is not acceptable.
examples/load-balancer-postgres-health.toml shows a PostgreSQL pre-auth
SSLRequest health-check shape for pools where a TCP connect is too weak but
authentication or SQL execution is not acceptable.
proxy.load_balance.health_check.protocol defaults to tcp, which verifies
TCP reachability and, when upstream_tls = true, a TLS handshake. Set
protocol = "http" to send method to path; method defaults to GET and
must be an uppercase HTTP token. By default only 200 passes, or
expected_statuses = [200, 204] can define an explicit allow-list.
expected_status_ranges = [{ start = 200, end = 399 }] accepts inclusive
HTTP status ranges and can be combined with exact statuses.
HTTP-family health checks may include bounded custom request headers for
authenticated or tenant-scoped health endpoints:
[[proxy.load_balance.health_check.request_headers]]
name = "Authorization"
value = "Bearer health-check-token"Request headers are valid only for HTTP and gRPC health checks, are capped at
16 entries and 1024 bytes per value, reject duplicate names
case-insensitively, and reserve hop-by-hop headers plus Host. Use
host = "app.internal" for the HTTP Host header. Header values are not exposed
in metrics labels or runtime status, and are redacted from serialized config
views.
Set protocol = "grpc" to run the standard gRPC Health Checking Protocol over
HTTP/2. Fluxheim sends POST /grpc.health.v1.Health/Check with a bounded
hand-encoded request body and expects HTTP 200, content-type: application/grpc, and a SERVING response message. grpc_service = "package.Service" optionally checks a specific gRPC service name. gRPC health
checks may use host, request_headers, timeout fields, connection reuse, and
port_override; HTTP status/header/body matchers are rejected because the
standard gRPC health response has its own fixed semantics.
Set protocol = "redis" to run a bounded Redis health check. Fluxheim opens a
TCP connection to the selected backend, sends one fixed RESP PING frame, and
requires a simple-string +PONG response. Redis checks use
connect_timeout_secs and read_timeout_secs, but reject request headers,
HTTP/gRPC response matchers, host, port_override, connection reuse, and
parallel = true. Redis health checks are probes only: they do not
authenticate, inspect keys, execute arbitrary Redis commands, or make Fluxheim
a Redis proxy. Redis TLS and authenticated Redis checks remain future work.
For local proof against a real Redis-compatible server, run
scripts/smoke_redis_health_check.sh; it starts Valkey in Podman, verifies
Valkey observes Fluxheim's PING, then stops Valkey and checks that Fluxheim
marks the Redis backend unhealthy.
Set protocol = "mysql" to run a bounded MySQL/MariaDB handshake health
check. Fluxheim opens a TCP connection to the selected backend, reads one
bounded MySQL server greeting packet, and requires a protocol-10 handshake with
a terminated server-version field. MySQL checks use connect_timeout_secs and
read_timeout_secs, but reject request headers, HTTP/gRPC response matchers,
host, port_override, connection reuse, and parallel = true. MySQL checks
are probes only: they do not authenticate, send a login packet, execute SQL,
inspect schemas, or make Fluxheim a MySQL proxy. MySQL TLS and authenticated
readiness checks remain future work. Because the probe intentionally disconnects
before authentication, non-loopback MySQL/MariaDB servers can count repeated
idle health probes against their host-cache error budget (max_connect_errors)
and eventually block all connections from the Fluxheim host until FLUSH HOSTS
or equivalent host-cache cleanup. For MySQL pools with low real traffic, set a
larger max_connect_errors, use conservative health-check intervals and
failure thresholds, or use an authenticated exec health check such as
mysqladmin ping when credentialed readiness is required.
For local proof against a real MySQL-compatible server, run
scripts/smoke_mysql_health_check.sh; it starts MariaDB in Podman, verifies
Fluxheim increases MariaDB's unauthenticated handshake counter, then stops
MariaDB and checks that Fluxheim marks the backend unhealthy.
Set protocol = "postgres" to run a bounded PostgreSQL protocol health check.
Fluxheim opens a TCP connection to the selected backend, sends PostgreSQL's
8-byte SSLRequest pre-auth handshake, and requires the one-byte S or N
SSLResponse. PostgreSQL checks use connect_timeout_secs and
read_timeout_secs, but reject request headers, HTTP/gRPC response matchers,
host, port_override, connection reuse, and parallel = true. PostgreSQL
checks are probes only: they do not authenticate, send a StartupMessage,
execute SQL, inspect schemas, or make Fluxheim a PostgreSQL proxy. PostgreSQL
TLS and authenticated readiness checks remain future work.
For local proof against a real PostgreSQL server, run
scripts/smoke_postgres_health_check.sh; it starts PostgreSQL in Podman,
verifies PostgreSQL observes Fluxheim's pre-auth connection, then stops
PostgreSQL and checks that Fluxheim marks the backend unhealthy.
Set protocol = "exec" to run an opt-in local command health check for
backends that cannot be represented by TCP/TLS, HTTP, gRPC, or JSON response
checks:
[proxy.load_balance.health_check]
protocol = "exec"
exec_command = "/usr/local/libexec/fluxheim-health"
exec_args = ["--probe"]
exec_allowed_commands = ["/usr/local/libexec/fluxheim-health"]
exec_timeout_secs = 2Exec health checks require an absolute command path without . or .. path
components, and that exact path must appear in exec_allowed_commands.
Fluxheim does not invoke a shell, does not inherit the process environment,
and connects stdin/stdout/stderr to null devices. The command receives only
bounded backend context through
FLUXHEIM_HEALTH_BACKEND_ADDR, FLUXHEIM_HEALTH_BACKEND_HOST, and
FLUXHEIM_HEALTH_BACKEND_PORT; host and port are empty for non-inet backend
addresses. exec_args are literal argv entries, not shell fragments.
exec_timeout_secs follows the normal health-check timeout bounds. Exec
checks are serial per pool in this release; parallel = true is rejected for
exec checks to avoid spawning many local processes at once. HTTP/gRPC
request-header and response-matcher fields, host, port_override,
connect_timeout_secs, and read_timeout_secs are rejected on exec checks so
this remains a local monitor, not a scripting engine.
Runtime load-balancer status exposes only the health-check protocol name
(tcp, http, grpc, redis, mysql, postgres, or exec) for operator
visibility; it does not expose exec command paths or arguments. Exec backend
summaries likewise identify the check as via exec without including the
configured command path.
Do not place secrets in exec_command, exec_args, or
exec_allowed_commands: they are normal configuration fields and may appear in
local config files, snapshots, backups, or operator review output. Use a local
root/service-owned helper that reads its own protected credential file if the
probe needs credentials.
expected_body_json performs bounded exact scalar matching against JSON health
responses without JSONPath, array indexing, expressions, or regexes:
expected_body_json = [
{ path = "status", equals = "ok" },
{ path = "database.connected", equals = "true" },
{ path = "queue_depth", equals = "42" },
]Paths are dot-separated object fields capped at 256 bytes; values are compared
as strings after scalar JSON conversion. Object and array values are not
matchable.
HTTP and gRPC health responses may include X-Health-Weight: N, where N is
an integer from 1 through 100. Values below 100 reduce the backend's
effective selection weight to that percentage of its configured/admin runtime
weight while the backend remains healthy; 100 or an absent header clears the
health-derived override. health_weight_min_percent defaults to 25, so a
backend cannot self-report below 25% of its base weight unless the operator
explicitly lowers that floor. Invalid values fail the health check. Runtime
status exposes this as health_weight_percent separately from configured
weight and admin runtime weight overrides.
expected_headers can require exact response header values:
expected_headers = [{ name = "x-fluxheim-health", value = "ready" }].
expected_body_contains = ["ready"] requires each configured byte substring
to appear in the HTTP health response body. Fluxheim reads at most 64 KiB of a
health-check body for this validation.
host overrides the health-check Host header and TLS SNI fallback,
reuse_connection = true allows Pingora to reuse check connections, and
port_override sends checks to a different port on the same backend address.
Omit port_override to check the backend's normal port. connect_timeout_secs
and read_timeout_secs are optional active-check overrides; when omitted,
checks inherit the proxy upstream timeout where applicable and otherwise use
Pingora's health-check defaults.
proxy.load_balance.passive_health.enabled = true adds opt-in passive outlier
detection. Fluxheim records selected upstream outcomes, treats 5xx responses as
failures by default, and temporarily ejects a backend after
consecutive_failure failures for ejection_secs. failure_statuses may
narrow the failure set to specific 5xx status codes, and
failure_status_ranges accepts inclusive 5xx ranges such as
[{ start = 520, end = 529 }]. max_latency_ms = 0 disables latency ejection;
a positive value treats responses at or above that latency as passive failures.
Passive ejection is also exposed in load-balancer runtime status as
circuit_state = "open" with a pool-level circuit_open_backend_count so
temporary outlier removal is explainable through the admin plane.
Active health checks and passive ejection are combined; if no backend is
currently selectable,
Fluxheim returns a proxy error instead of falling back to a configured primary
upstream.
proxy.load_balance.slow_start.enabled = true warms newly seen and passively
recovered load-balanced backends over duration_secs before they receive their
full normal selection share. If all otherwise healthy candidates are still
warming, Fluxheim allows a selection instead of failing the entire pool, so
slow-start is a traffic-shaping guard rather than an availability blocker.
proxy.load_balance.retry.enabled = true enables bounded redispatch for
connection failures that happen before the request is sent upstream. It is
limited by max_retries, by Pingora's process-wide retry cap, and by
methods, which accepts only safe method tokens such as GET, HEAD,
OPTIONS, and TRACE. statuses = [500, 502, 503] can additionally
redispatch selected HTTP 5xx responses before response streaming starts, and
status_ranges = [{ start = 520, end = 529 }] accepts inclusive 5xx ranges.
Empty statuses and status_ranges keep response-status retries disabled.
budget_per_window = 0 disables the shared retry budget; set it to a positive value with
budget_window_secs to cap total redispatch attempts for this vhost or route
over a moving window. Fluxheim does not retry after response streaming has
started.
proxy.load_balance.queue is disabled by default. Set both max_waiting and
timeout_ms to let a bounded number of requests wait briefly when no backend is
selectable, for example because every member is at its per-upstream
upstream_max_in_flight cap. max_waiting = 0 and timeout_ms = 0 preserve
the default immediate all_down_status behavior. When enabled, max_waiting
is capped at 100000, timeout_ms at 60000, and retry_interval_ms must be 1
through 1000. Waiting requests occupy only the load-balancer queue counter; no
upstream permit is held until a backend becomes selectable.
proxy.load_balance.persistence.enabled = true enables a bounded local
persistence table. mode = "source-ip" maps a client IP to the selected
backend for ttl_secs; mode = "header" maps the configured request header
value, for example an operator-trusted session header; mode = "cookie" maps
the configured request cookie value from the client request. mode = "managed-cookie" creates a Fluxheim-owned signed/opaque affinity cookie with
Set-Cookie on eligible 2xx/3xx backend responses, verifies that cookie on
later requests, and maps the opaque cookie key to the selected backend in the
same bounded local table. Managed-cookie values do not expose backend
addresses, aliases, or weights. Configure the cookie name through cookie = "fluxheim_lb" and optional attributes through managed_cookie_domain,
managed_cookie_path (default /), managed_cookie_secure (default true),
managed_cookie_http_only (default true), managed_cookie_same_site
(default lax), and managed_cookie_max_age_secs (default ttl_secs).
SameSite=None requires managed_cookie_secure = true. Managed-cookie
signing keys are generated locally at process start, rotated daily, and verified
against the current or previous key generation so in-flight cookies survive a
normal rotation window.
Stored backends are reused while they remain ready, not
drained/disabled/forced-down, not passively ejected, and below their in-flight
cap. If the stored backend is no longer selectable, Fluxheim falls back to the
normal load-balancing algorithm and refreshes the table with the new backend.
table_max_entries bounds memory use; expired entries are pruned and the oldest
expiry is evicted when the table is full. Without
proxy.load_balance.runtime_state_file, persistence is local to one Fluxheim
process and is reset by process restart, runtime rebuild, or the authenticated
persistence-clear admin operation. It is rejected in privacy-mode builds
because persistence retains client-derived identifiers.
proxy.load_balance.runtime_state_file enables local restart persistence for
runtime member-state overrides, runtime weight overrides, and bounded local
persistence-table entries. The state file is JSON, versioned, size-limited,
written atomically with a private file mode, and read with symlink checks.
Corrupt, oversized, incompatible, or stale state is ignored and rebuilt instead
of poisoning the pool. The file is local to one Fluxheim process and does not
share managed-cookie signing keys across nodes.
When persistence is enabled, the state file includes local affinity table keys.
source-ip mode writes client IP bytes, header mode writes the configured
header value, and cookie mode writes the configured cookie value.
managed-cookie mode writes opaque affinity keys generated by Fluxheim. Use
managed-cookie for session affinity when possible, or place the state file on
an encrypted, access-restricted volume when raw header or cookie identifiers are
used.
The current 1.5.x load-balancer line does not add/remove pool members at
runtime, apply runtime weights to hash/ring selectors, share managed-cookie
signing keys across nodes, or synchronize load-balancer state across
active-active Fluxheim nodes. Managed-cookie HA mirroring is tracked separately
from the local managed-cookie table shipped in 1.5.3; see
Load Balancer HA Design Notes.
upstreams is the preferred static proxy target form for both one and many origins.
The older single upstream = "host:port" field remains supported for simple
configs, but do not set both fields in the same proxy block. Fluxheim rejects
that as ambiguous. upstreams_file is also mutually exclusive with both static
forms. A single upstreams = ["host:port"] entry behaves like a
normal single proxy target in all builds and is resolved when requests are
proxied, so a missing backend does not prevent the gateway from starting. Two
or more entries activate the Fluxheim load-balancer path in builds compiled
with load-balancer; those entries may be resolved by load-balancer setup and
health checking. File-refreshed and DNS-refreshed pools also use the
load-balancer path and keep serving the previous healthy set when a later
refresh is invalid. The same proxy.load_balance policy applies inside
[[vhosts.routes.proxy]] route proxy blocks; route-level pools get their own
selection, passive-health, retry, and health-check state.
connect_timeout_secs, read_timeout_secs, and send_timeout_secs are
optional. They map to the upstream connection timeout, upstream response/read
timeout, and upstream request-body/write timeout.
websocket = true enables HTTP/1.1 upgrade forwarding for websocket-style or
other token-based upgrade requests on that proxy block. Fluxheim validates this
with upstream_http_version = "http1" because HTTP/2 origins do not use the
same hop-by-hop upgrade mechanism.
downstream_write_timeout_secs and
downstream_min_send_rate_bytes_per_sec protect the client-facing side of
proxied responses. The write timeout caps stalled downstream writes and defaults
to 30 seconds so HTTP/2 clients cannot hold response bodies indefinitely with a
zero receive window. The minimum send rate asks Pingora to derive a timeout from
each response chunk size and is mainly useful against slow HTTP/1 clients. These
fields are optional and can be set globally, per vhost, or on a route-level
proxy block.
Fluxheim also installs hardened downstream HTTP/2 handshake defaults whenever
HTTP/2 is enabled: decoded request header lists are capped at 64 KiB per stream
and remotely initiated concurrent streams are capped at 32 per connection. These
service-level caps are applied before vhost routing because HTTP/2 negotiation
happens before a Host/:authority value can be trusted.
When websocket = true and the downstream request contains a valid
Connection: Upgrade token plus a valid Upgrade token, Fluxheim forwards the
request upstream with Connection: upgrade and the downstream upgrade token.
Upgrade requests bypass proxy cache policy and should normally use route-level
read/send timeouts sized for long-lived connections. Leave websocket = false
on normal HTTP routes so hop-by-hop upgrade headers are not forwarded
accidentally.
[proxy.auth_request] is Fluxheim's NGINX-style external authorization hook for
proxy actions. When enabled, Fluxheim sends a bounded GET subrequest to url
before forwarding the real request. Only headers listed in forward_headers
are copied to the auth endpoint. Any 2xx auth response allows the request and
headers listed in allow_response_headers are copied into the upstream request;
4xx/5xx auth responses stop the request and return the auth status with a
bounded text body. Other auth statuses are treated as a gateway-side auth
failure. The hook can be configured globally, per vhost proxy, or per route
proxy block. In FIPS/ISO-required mode, auth subrequests are limited to numeric
local http://127.0.0.1/... or http://[::1]/... sidecars until outbound TLS
client evidence is routed through the selected validated provider. With metrics
enabled, auth subrequest decisions are counted by
fluxheim_edge_policy_events_total with bounded auth_request policy labels
and allow, deny, or error outcomes.
[[proxy.error_pages]] entries are internal static fallback pages for proxy
failures. The path is an internal request path resolved below the entry's
web.root; it is not exposed as a public route unless you also configure a
route for that root.
[proxy.mirror] is an opt-in traffic shadowing hook available in binaries
built with the traffic-mirror feature. The first implementation mirrors only
safe, bodyless requests (GET, HEAD, OPTIONS, or TRACE) to base_url
and appends the original path and query. Mirror requests are fire-and-forget:
timeouts, response statuses, and failures never affect the primary response.
Only headers listed in forward_headers are copied; credentials and cookies are
not mirrored unless explicitly allow-listed. sample_per_mille deterministically
selects 1 through 1000 requests per 1000 for a stable method/host/path key.
max_response_bytes bounds how much of the mirror response Fluxheim drains
before discarding it. max_in_flight caps outstanding mirror worker tasks per
vhost/route mirror key; requests above the cap skip mirroring and continue on
the primary path. Mirroring is rejected in privacy-mode; in FIPS/ISO required
mode it is limited to numeric local http://127.0.0.1/... or
http://[::1]/... sidecars until outbound TLS client evidence is routed
through the selected validated provider.
[compression] is a global opt-in response compression policy. It is available
only in binaries built with one or more codec features: compression-gzip,
compression-zstd, or compression-brotli. The 1.4 production profile aliases
used for official full/cache/proxy/PHP artifacts compile all three codecs so
operators can enable compression by vhost or route without rebuilding; default
developer builds still omit compression code. privacy-mode builds reject
compression at compile time.
[compression]
enabled = true
gzip = true
zstd = false
brotli = false
min_bytes = "1KiB"
max_input_bytes = "1MiB"
max_output_bytes = "2MiB"
gzip_level = 4
zstd_level = 3
brotli_quality = 4Each vhost inherits the global compression policy unless it declares
[vhosts.compression]. Each route inherits the vhost compression policy unless
it declares [vhosts.routes.compression]. This lets one site enable
compression while another site continues to serve identity responses, or lets a
specific path prefix opt in or out:
[[vhosts]]
name = "docs"
hosts = ["docs.example.com"]
[vhosts.compression]
enabled = true
gzip = true
zstd = true
brotli = false
min_bytes = "1KiB"
max_input_bytes = "2MiB"
max_output_bytes = "4MiB"
[[vhosts.routes]]
name = "uploads"
path_prefix = "/wp-content/uploads/"
[vhosts.routes.proxy]
upstream = "127.0.0.1:8080"
[vhosts.routes.compression]
enabled = true
gzip = true
zstd = true
min_bytes = "1KiB"
max_input_bytes = "2MiB"
max_output_bytes = "4MiB"The compression path compresses only eligible GET responses with known
Content-Length, status 200, a matching client Accept-Encoding, no
existing Content-Encoding, no Set-Cookie, no request Cookie or
Authorization, no Content-Range, no Cache-Control: no-transform, and a
conservative text, JavaScript, JSON, XML, or SVG media type. Fluxheim prefers
br, then zstd, then gzip when those codecs are enabled and accepted by
the client. Fluxheim removes Content-Length and ETag from compressed
responses and adds Vary: Accept-Encoding.
min_bytes and max_input_bytes bound the original response size.
max_output_bytes bounds the encoded response size. The configured input
maximum cannot exceed 64 MiB and the output maximum cannot exceed 128 MiB.
gzip_level must be between 0 and 9, zstd_level between 1 and 19,
and brotli_quality between 0 and 11.
[web]
root = "/srv/sites/example"
index_files = ["index.html"]
deny_dotfiles = true
cache_control = "public, max-age=60"
expires = "Wed, 21 Oct 2030 07:28:00 GMT"
[web.directory_listing]
enabled = false
exact_size = false
local_time = falseStatic serving requires web.root to be a real directory, not a symlink and
not below a symlinked parent directory. Request paths are symlink-free,
including intermediate directories. Static serving also rejects traversal,
dotfiles by default, and unknown nested index file names. index_files is
capped at 32 entries. Static body reads
re-check the opened file handle and full-body reads are length-exact, failing
if the file changes while it is being read. The current static response path is
buffered and refuses response bodies larger than 64 MiB; larger-file streaming
is planned before this limit is relaxed. Static responses support MIME
detection, GET/HEAD, ETag, If-Match, If-Unmodified-Since,
If-None-Match, If-Modified-Since, and single byte ranges.
web.directory_listing is disabled by default. When enabled, Fluxheim only
generates a listing after no index file matches. Listings inherit dotfile
protection, skip symlink entries, cap entry count, and use private, no-store
so repository indexes are not accidentally cached by shared intermediaries.
local_time = true renders listing modification times with the server's local
UTC offset; otherwise listings use GMT HTTP-date timestamps.
cache_control is emitted on static responses and defaults to
public, max-age=60. Use response header policy when you need to append or
unset CDN-specific headers such as Vary, Surrogate-Control, or
provider-specific cache controls. expires is optional and must be an HTTP
header-safe value when set. Per-vhost static settings use [vhosts.web].
[cache] is disabled by default at runtime even when the cache feature is
compiled.
[cache]
preset = "none"
enabled = false
local_static = false
status_header = "X-Cache-Status"
status_reason_header = "X-Cache-Reason"
hide_response_headers = ["set-cookie"]
tag_headers = ["surrogate-key", "cache-tag", "x-cache-tags"]
no_store_response_headers = ["x-app-no-store"]
no_store_response_header_values = { x-app-cache = "private" }
bypass_path_prefixes = ["/wp-admin/"]
bypass_path_exact = ["/wp-login.php", "/xmlrpc.php"]
bypass_request_headers = ["cookie", "authorization"]
bypass_request_header_values = { x-preview-mode = "1" }
bypass_cookie_names = ["sessionid", "wordpress_logged_in"]
bypass_cookie_name_prefixes = ["wordpress_logged_in_", "wordpress_sec_"]
bypass_cookie_values = { preview = "1" }
bypass_query_params = ["preview", "token"]
bypass_query_values = { mode = "private" }
bypass_query = false
allow_client_cache_refresh = false
vary_request_headers = ["accept-encoding"]
ignore_origin_cache_headers = false
key_namespace = "repoheim-assets-v1"
key_parts = ["method", "host", "path", "query"]
min_uses = 2
pass_uncacheable_after = 3
status_ttls = { "200" = 3600, "404" = 60 }
default_status_ttl_secs = 15
stale_while_revalidate_secs = 30
stale_if_error_secs = 120
stale_if_error_on = ["connect", "timeout", "http-status"]
stale_if_error_statuses = [500, 502, 503, 504]
include_query = true
content_types = ["image/*", "text/css", "application/javascript", "font/*"]
extensions = ["avif", "css", "gif", "ico", "jpg", "js", "png", "svg", "webp", "woff2"]
methods = ["GET", "HEAD"]
max_object_bytes = "32MiB"
[cache.range]
enabled = false
max_bytes = "8MiB"
[cache.memory]
enabled = false
max_size_bytes = "1GiB"
[cache.disk]
enabled = false
backend = "filesystem"
path = "/var/cache/fluxheim"
max_size_bytes = "10GiB"
[cache.disk.storage_bin]
bin_size_bytes = "256MiB"
preallocate = false
max_open_bins = 16
[cache.disk.encryption]
enabled = false
provider = "local"
algorithm = "aes-256-gcm"
# key_id = "cache-v1"
# key_file = "/run/secrets/fluxheim-cache-key"
# key_credential = "fluxheim-cache-key"
# Optional OpenBao Transit provider for external key custody:
# provider = "openbao-transit"
#
# [cache.disk.encryption.openbao]
# address = "https://openbao.internal.example"
# mount = "transit"
# key_name = "fluxheim-cache"
# token_credential = "openbao-token"
[cache.lock]
enabled = true
age_timeout_secs = 30
wait_timeout_secs = 30
[cache.predictor]
enabled = false
capacity = 65536If cache.enabled = true, at least one storage tier must be enabled.
Each enabled tier must be at least as large as max_object_bytes.
Disk cache requires cache.disk.path. The disk cache root must be a real
directory and must not sit below a symlinked parent directory. On Unix,
Fluxheim also rejects disk cache roots whose nearest existing parent is
group- or world-writable, such as creating a cache root directly below /tmp; use a
dedicated cache directory such as /var/cache/fluxheim or a pre-created private
runtime directory.
[cache.range] is disabled by default. When enabled, Fluxheim can cache safe
bounded single Range: bytes=start-end proxy responses under a range-specific
cache key. This is intended for large object workloads such as package mirrors,
media files, and resumable downloads where clients repeatedly request the same
byte window. Fluxheim only admits matching upstream 206 Partial Content
responses whose Content-Range and Content-Length match the requested range;
unkeyed upstream 206 responses are rejected from the normal full-object cache
to avoid poisoning complete-object entries. Without slice caching,
range.max_bytes must be greater than zero and no larger than
cache.max_object_bytes.
[cache.range.slice] enables the 1.2.6 fixed-slice range cache. Fluxheim
normalizes client ranges into fixed-size slices, stores each slice under a
slice-specific key, and can compose fresh compatible slices into single-range,
open-ended, suffix, or multipart/byteranges responses. Missing slices can be
filled from origin with bounded single-slice Range requests when
fill_missing = true; concurrent fills for the same slice key are collapsed.
Slice fill rejects responses unless 206, Content-Range, Content-Length,
content type, object length, and validators are compatible. If-Range requests
are served from slices only when the cached ETag or Last-Modified matches;
otherwise Fluxheim falls back to the normal proxy path. Exact admin purges also
remove indexed slices for the same request path.
[cache.range]
enabled = true
max_bytes = "128MiB"
[cache.range.slice]
enabled = true
size_bytes = "1MiB"
max_slices = 128
fill_missing = trueWhen range.slice.enabled = true, range.max_bytes may be larger than
cache.max_object_bytes, but range.slice.size_bytes must not exceed
cache.max_object_bytes, and range.max_bytes must not exceed
range.slice.size_bytes * range.slice.max_slices.
cache.disk.backend defaults to filesystem, the stable complete-object disk
backend used by 1.2.0 and 1.2.1. storage-bin selects the focused 1.2.2
slab/bin disk backend, which stores objects inside bounded .fhbin data files
with a durable object index and free-range reuse. The [cache.disk.storage_bin]
table defines the allocator shape:
bin_size_bytes must be at least cache.max_object_bytes and no larger than
cache.disk.max_size_bytes, preallocate controls whether Fluxheim should
reserve full bin files ahead of object writes, and max_open_bins bounds the
number of concurrently opened bin files.
[cache.disk.encryption] is disabled by default. When enabled = true with
provider = "local", Fluxheim encrypts disk cache objects with AES-256-GCM
before they are written to the filesystem or storage-bin backend. The local key
must be a 64-character hex-encoded 256-bit key loaded from exactly one of
key_file or key_credential; credential names are resolved through
$CREDENTIALS_DIRECTORY when present or /run/secrets otherwise. key_id
is stored with the encrypted object and is included with the combined cache key
as authenticated data, so objects cannot be silently swapped between cache
keys. Local cache encryption is intended for cache-at-rest protection; it does
not encrypt memory cache contents.
provider = "openbao-transit" uses OpenBao Transit for regulated deployments
that need centralized key custody and rotation. Fluxheim calls the Transit
encrypt and decrypt endpoints for disk cache objects and stores only the
returned vault:v... ciphertext in the filesystem or storage-bin backend. The
OpenBao endpoint must be HTTPS unless it is loopback HTTP, and the token must
come from exactly one safe token_file or token_credential source. The
configured key id plus combined cache key are passed as associated data, so a
stored ciphertext is bound to the cache object identity. The default local-key
provider does not require OpenBao.
In FIPS/ISO-required mode, OpenBao Transit is further restricted to local
numeric loopback HTTP (http://127.0.0.1 or http://[::1]). Remote OpenBao
and HTTPS OpenBao endpoints require outbound TLS evidence that Fluxheim does
not provide yet.
For local validation, examples/podman-compose-openbao.yml starts an OpenBao
development server and scripts/smoke_openbao_cache_encryption.sh runs an
end-to-end proxy-cache test against OpenBao Transit. The smoke test enables the
Transit engine, creates a cache key, serves a cacheable object through
Fluxheim, verifies MISS then HIT, and checks that the stored cache object
contains OpenBao vault:v... ciphertext rather than the plaintext response
body. It is intentionally optional because normal CI should not depend on a
local Podman/OpenBao runtime.
examples/cache-encryption-local.toml and
examples/cache-encryption-openbao.toml provide full example policies using
the storage-bin backend with local-key and OpenBao Transit encryption. See
docs/cache-encryption.md for key setup and rotation guidance.
local_static is disabled by default. When set to true, the same cache
policy may also store local [web], [vhosts.web], and route-scoped
[vhosts.routes.web] file responses. Local static caching is opt-in because it
changes an otherwise direct file-read path into a shared in-process cache path.
Fluxheim keys local static cache objects by the request cache key plus canonical
file identity metadata, so a changed local file creates a new cache key instead
of serving the old body. Memory storage is preferred when both memory and disk
tiers are configured, avoiding a second disk copy of files that already exist
under the static site root. Disk-only cache policies are still accepted for
operators who explicitly want disk-backed local static caching.
status_header is optional. When set, Fluxheim emits a cache debug header such
as X-Cache-Status: HIT, MISS, STALE, BYPASS, EXPIRED, or
REVALIDATED for requests that participate in the proxy cache or opt-in local
static cache.
status_reason_header is optional. When set, Fluxheim emits a bounded reason
header such as OriginNotCache, ResponseTooLarge, or cache-min-uses when
the cache phase has an explicit no-cache reason. Leave it unset unless you are
actively debugging cache policy.
[cache.predictor], [vhosts.cache.predictor], and
[vhosts.routes.cache.predictor] are opt-in Pingora cacheability predictors.
When enabled, Fluxheim can remember recent origin-level uncacheable outcomes
such as private/no-store cache responses or oversized responses and bypass
future cache lookup and cache locking for the same primary key until the
bounded predictor entry ages out of its LRU table. Fluxheim-specific custom
policy reasons are intentionally skipped so settings such as min_uses,
configured request bypasses, and explicit response-header refusal policies stay
controlled by Fluxheim's own policy counters.
[cache.peer_fill], [vhosts.cache.peer_fill], and route-scoped
peer_fill configure the distributed-cache peer-fill contract used by the
1.2.4 line. Peer fill is disabled by default and currently requires the
owning cache policy to be enabled. The configuration is intentionally strict so
runtime peer retrieval can stay bounded:
[cache.peer_fill]
enabled = true
connect_timeout_secs = 2
read_timeout_secs = 10
max_object_bytes = "32MiB"
max_concurrent_requests = 64
allow_insecure_http = false
fail_open = true
[[cache.peer_fill.peers]]
name = "edge-a"
base_url = "https://edge-a.internal.example:8443"
[[cache.peer_fill.peers]]
name = "edge-b"
base_url = "https://edge-b.internal.example:8443"peers must contain between 1 and 32 entries when peer fill is enabled.
Peer names are short ASCII identifiers. Peer base_url values must be
HTTP(S) origins with an explicit host:port, no userinfo, no query or
fragment, and no path beyond /. Plain HTTP is accepted only for loopback
peers unless allow_insecure_http = true, which is intended for private test
networks or trusted in-cluster transport. max_concurrent_requests is bounded
to 1-1024 and fail_open = true means peer-fill failure should fall back to the
normal origin path rather than failing the user request. max_concurrent_requests
is enforced per vhost or route cache policy for active outbound peer-fill
fetches. If that limit is saturated, Fluxheim follows fail_open: fallback to
origin when allowed, or a bounded 504 miss response otherwise. The first
runtime primitive is available now:
proxy-cache requests with Cache-Control: only-if-cached are answered only from
a fresh local cache object and otherwise return 504 without contacting origin.
Outbound peer fill uses the same safe request mode on local proxy-cache misses,
stores valid peer hits locally, and falls back to origin only when fail_open
is true. Peer requests include the original host plus safe negotiation headers
such as Accept, Accept-Encoding, and Accept-Language; credentials such as
Authorization and Cookie are not forwarded.
examples/cache-peer-fill.toml shows the focused validated fixture. Metrics
builds expose aggregate peer-fill configuration through
fluxheim_cache_peer_fill_enabled_policies,
fluxheim_cache_peer_fill_peers, and
fluxheim_cache_peer_fill_max_concurrent_requests.
For offline debugging, fluxheim cache-key --host example.com --path /assets/app.js previews the vhost/route cache policy and generated cache key
without contacting the upstream. cache-key can fail closed with
--expect-eligible, --expect-ineligible, --expect-reason,
--expect-cache-lock-enabled, --expect-cache-lock-wait-timeout-secs,
--expect-cache-predictor-enabled, --expect-peer-fill-enabled,
--expect-peer-fill-peers, --expect-peer-fill-max-concurrent-requests,
--expect-memory-tier-enabled, --expect-disk-tier-enabled, and
--expect-storage-tiers when a deploy requires a specific cache policy layout.
Use --expect-scope vhost|route, --expect-vhost NAME, and
--expect-route NAME when a deploy must prove that a specific vhost or route
policy was selected. Use --expect-namespace NAME for the internal cache
namespace and --expect-key-namespace NAME / --expect-user-tag TAG when
cache namespace migrations or purge-scope automation must fail closed on the
exact selected key space.
fluxheim cache-lookup --host example.com --path /assets/app.js also checks
configured cache tiers and prints safe object
metadata without dumping bodies or header values, including a compact
fresh/stale/expired state and stale-serving eligibility booleans. Both commands
accept repeated --header "Name: value" options for safe negotiated variant
inspection, such as Accept-Language or Accept-Encoding; use --host for
the Host header. cache-lookup can fail closed for deploy scripts with
--require-object, --expect-tier memory|disk, --expect-status,
--expect-body-bytes, --expect-fresh-ttl-secs, --expect-cache-tag,
--expect-header-name, --expect-header "Name: value", --expect-objects,
--expect-cache-lock-enabled,
--expect-cache-lock-wait-timeout-secs, --expect-cache-predictor-enabled,
--expect-peer-fill-enabled, --expect-peer-fill-peers,
--expect-peer-fill-max-concurrent-requests, --expect-memory-tier-enabled,
--expect-disk-tier-enabled,
--expect-storage-tiers, --expect-scope, --expect-vhost,
--expect-route, --expect-namespace, --expect-key-namespace,
--expect-user-tag,
--expect-ineligible, --expect-reason,
--expect-serve-stale-if-error,
--expect-serve-stale-while-revalidate, --expect-purge-indexed, and
--expect-freshness-state fresh|stale|expired.
fluxheim cache-warm --header "Name: value" warms negotiated variants with
the same safe request-header syntax, and fluxheim cache-warm --dry-run
validates bounded warm target input files, repeat counts, cache-status
expectations, request headers, and listener selection without sending requests,
which is useful
before release deploy jobs.
Proxy cache storage currently bypasses HEAD requests with
X-Cache-Reason: method-head to avoid unsafe body handling; this keeps HEAD
probes from corrupting GET cache entries. Full HEAD-to-GET cache parity is a
future compatibility feature, not the 1.2 stable behavior.
hide_response_headers removes selected upstream response headers before cache
admission and downstream delivery. Use it only on tightly matched cache routes,
for example to strip Set-Cookie from known static asset responses.
tag_headers controls which origin response headers are trusted as cache-tag
sources for indexed tag purge. The default is surrogate-key, cache-tag, and
x-cache-tags. Set it to a smaller list for application-specific tag headers,
or to [] to disable cache-tag indexing while keeping scope, prefix, stale,
and wildcard purge available.
no_store_response_headers rejects shared cache admission when any listed
origin response header is present, while still delivering the response to the
client. Use it for application-specific no-store signals that are not expressed
through standard Cache-Control directives.
no_store_response_header_values rejects shared cache admission only when a
listed origin response header has the exact configured value. Use it for
bounded app signals such as x-app-cache = "private" when header presence
alone is too broad.
preset = "wordpress" expands common WordPress shared-cache bypasses for
admin/login paths, app/mail/register/index and sitemap endpoints,
auth-related cookies, any non-empty query string, and authorization headers.
Explicit fields still work normally and are not removed by the preset.
Cache bypass, header, status, vary, content-type, extension, and method lists
are capped to bounded sizes to keep validation and per-request matching work
predictable.
bypass_path_prefixes and bypass_path_exact disable both cache lookup and
storage for matching request paths. Prefixes are useful for app admin areas;
exact paths are useful for login, XML-RPC, cron, sitemap, or legacy WordPress endpoints.
bypass_request_headers disables both cache lookup and cache storage when any
listed request header is present. Use it on routes where a header such as
Cookie or Authorization changes the upstream response but should not become
part of the shared cache identity. The default is empty so explicit static
asset routes can still cache browser requests that carry unrelated cookies.
bypass_request_header_values disables lookup and storage only when a listed
request header has the exact configured value. Use it for bounded flags such as
x-preview-mode = "1" when header presence alone is too broad.
bypass_cookie_names disables both cache lookup and cache storage when a
listed cookie name appears in any Cookie request header. Only exact names are
matched; values are ignored. bypass_cookie_name_prefixes applies the same
behavior to cookie-name prefixes such as WordPress hashed login cookies.
This is narrower than bypassing on every Cookie header and is useful for
static routes where only session or preview cookies make the response unsafe to
share.
bypass_cookie_values disables both cache lookup and cache storage when a
listed cookie name appears with the exact configured value. Use it for bounded
flags such as preview = "1" when the cookie name alone is too broad.
bypass_query = true disables both cache lookup and cache storage for any
non-empty query string. This matches common WordPress FastCGI cache examples
where query-string requests are treated as dynamic.
bypass_query_params disables both cache lookup and cache storage when the raw
request query string contains any listed parameter name. Matching is exact on
the raw key before =, so preview=true matches preview, while
previewed=true does not. Use it for preview, token, or other app-specific
query switches that make a response unsafe to share.
bypass_query_values disables both cache lookup and cache storage when a raw
query parameter has the exact configured value. Matching is performed before
URL decoding, so keep values simple and encode spaces or separators at the
application edge.
allow_client_cache_refresh is disabled by default. When disabled, client
headers such as Cache-Control: no-cache, Cache-Control: max-age=0, and
Pragma: no-cache do not force upstream revalidation, which keeps unauthenticated
clients from neutralizing the shared cache. Enable it only on routes where
browser-style refresh semantics are explicitly desired. Cache-Control: no-store still bypasses lookup and storage because the client explicitly
forbids storing the response.
vary_request_headers adds safe request headers to the cache variance key even
when the origin does not emit a matching Vary header. Use this for negotiated
static assets, for example Accept-Encoding. Sensitive request-specific
headers such as Cookie, Authorization, and Proxy-Authorization are
rejected here; use bypass_request_headers for those.
key_namespace is optional. When set, Fluxheim adds the string to the primary
cache key, which gives operators a simple cache-versioning knob. Bump it, for
example from repoheim-assets-v1 to repoheim-assets-v2, to isolate new
objects from an older route cache without changing URLs.
key_parts controls which safe request fields are included in the primary
cache key. Valid values are method, host, path, and query; the list is
capped at 4 entries, path is required, and duplicates are rejected. This gives
operators the useful part of cache-key templates without allowing arbitrary
interpolation. query is still ignored when include_query = false.
min_uses delays cache admission until the same cache key has produced a
cacheable origin response at least that many times within a short bounded
window. The default is 1, which stores the first cacheable response. Increase
it on routes where one-off URLs should pass through without occupying shared
cache space.
pass_uncacheable_after is disabled by default with 0. When set, Fluxheim
counts repeated uncacheable origin responses for the same cache key in a bounded
short-lived in-memory table. After the configured threshold, matching requests
temporarily bypass cache lookup and storage instead of repeatedly entering the
shared cache path. A later cacheable response clears the pass decision.
When status_header and status_reason_header are configured, this policy is
reported as BYPASS with reason cache-pass.
ignore_origin_cache_headers removes upstream Cache-Control and Expires
before cache admission and downstream delivery. Keep the default false unless
the matched route is known static content and Fluxheim policy is responsible for
freshness.
status_ttls is optional. Each key is an HTTP status code and each value is a
positive TTL in seconds. When a cache-participating origin response matches, the
cache policy replaces response freshness headers with
Cache-Control: public, max-age=<ttl> before cache admission. Non-200 origin
responses are admitted only when their status appears in status_ttls, or when
default_status_ttl_secs is set as a fallback for any status. Use
default_status_ttl_secs carefully: it can make unusual or error statuses
cacheable on the matched route unless another admission rule rejects the
response. stale_while_revalidate_secs and stale_if_error_secs are optional
and must be greater than zero when set.
stale_while_revalidate_secs permits serving an already-stored stale object
while Fluxheim revalidates it in the background, and stale_if_error_secs
permits serving stale during upstream errors. Both windows are counted after
normal freshness expires. If stale_if_error_secs is unset, Fluxheim will not
serve stale solely because the upstream failed. stale_if_error_on optionally
narrows which upstream error classes may use that stale-on-error window. Valid
values are connect, timeout, read, write, connection-closed,
http-status, protocol, tls, and other. The default includes all
classes. stale_if_error_statuses optionally narrows HTTP-status stale serving
to selected 5xx origin statuses; when it is empty, any upstream 5xx status that
Pingora reports as stale-if-error eligible is allowed. content_types is the
allow-list for 200 OK origin
response media types. Entries may be exact media types such as text/css or
subtype wildcards such as image/*. extensions is the user-facing alias for
the request-path extension allow-list; the older image_extensions key remains
accepted for compatibility. A request must match the extension policy and a
200 OK response must match content_types before it can enter the shared
proxy cache. include_query controls whether the query string is part of the
cache key. It defaults to true; set it to false only on tightly matched
static-asset routes where query parameters are not part of the response
identity.
[cache.lock] controls request collapsing for concurrent misses on the same
cache key. Keep it enabled for expensive static misses and stampede protection:
one request fetches the origin object while matching readers wait for the cache
fill instead of all hitting the backend together. age_timeout_secs controls
how long an active writer lock is considered valid, while wait_timeout_secs
controls how long readers wait for the writer before falling back to their own
origin fetch.
Per-vhost cache settings use [vhosts.cache], [vhosts.cache.memory], and
[vhosts.cache.disk]. Route cache settings use [vhosts.routes.cache] and
the same nested memory, disk, and lock subtables.
[cache_purger] is a process-wide stale disk cleanup loop. It is disabled by
default and requires the cache feature.
[cache_purger]
enabled = false
interval_secs = 300
limit = 512
batches = 1When enabled, Fluxheim periodically scans indexed disk-cache entries for each
vhost and route cache, removes entries whose stored freshness window has
expired, and stops after the configured bounded limit and batches per
target. It does not walk arbitrary cache directories. Truncated non-dry-run
stale purges rotate scanned fresh entries to the back of the bounded index, so
later batches can reach expired entries that are behind a fresh front page.
Keep limit and batches modest on large production caches; the admin
/_fluxheim/cache/purge-stale endpoint remains available for explicit dry-runs
or larger operator-controlled cleanup windows. With metrics enabled,
fluxheim_cache_purger_runs_total{outcome} and
fluxheim_cache_purger_entries_total{result} show whether the background
purger is cleanly keeping up or returning truncated runs.
fluxheim_cache_purger_duration_seconds{outcome} reports per-tick cleanup
duration with bounded outcome labels and without cache paths or keys.
Aggregate storage-pressure gauges such as fluxheim_cache_memory_entries,
fluxheim_cache_memory_weighted_size_bytes, fluxheim_cache_memory_max_size_bytes,
fluxheim_cache_disk_entries, fluxheim_cache_disk_size_bytes, and
fluxheim_cache_disk_max_size_bytes are updated while metrics are enabled.
Use the protected admin cache-status endpoint when you need vhost or route
breakdowns.
[tls]
enabled = false
backend = "rustls"
profile = "intermediate"
min_protocol = "tls1.2"
alpn = "http1-and-http2"
curve_preferences = ["X25519", "CurveP256", "CurveP384"]
cipher_suites = [
"TLS_AES_256_GCM_SHA384",
"TLS_CHACHA20_POLY1305_SHA256",
"TLS_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
]
[tls.fips]
required = false
require_disk_cache_encryption = false
[tls.iso19790]
required = false
require_disk_cache_encryption = false
[[tls.certificates]]
cert_path = "tls/fullchain.pem"
key_path = "tls/key.pem"
[tls.client_auth]
mode = "off"
# ca_path = "tls/client-ca.pem"TLS backend values: rustls, openssl. boringssl and s2n are rejected
config values.
Exactly one matching TLS compile-time feature should be selected:
tls-rustls or tls-openssl, with tls-rustls-fips and tls-openssl-fips
for compliance builds. The default build uses tls-rustls.
TLS policy values:
profile = "modern": Mozilla-style modern baseline. It requires TLS 1.3 and allows only TLS 1.3 cipher suites.profile = "intermediate": the default production compatibility baseline. It requires TLS 1.2 or newer and uses the common AEAD ECDHE TLS 1.2 suites plus TLS 1.3 suites.profile = "compat": keeps the TLS 1.2-or-newer baseline explicit for sites that prioritize client compatibility. It currently maps to the same safe baseline asintermediate; older protocol support is not planned for normal listeners.
See examples/tls-modern.toml and examples/tls-intermediate.toml for complete checked examples.
min_protocol may be set to tls1.2 or tls1.3; VersionTLS12 and
VersionTLS13 are accepted as compatibility aliases for operators migrating
from router-style TLS option files. modern rejects min_protocol = "tls1.2"
so the named modern policy cannot be weakened by accident. alpn may be
http1, http2, or http1-and-http2. The default is http1-and-http2,
matching the 1.0 listener behavior.
The rustls and OpenSSL backends enforce the configured minimum protocol, ALPN
policy, curve preferences, and cipher suite allow-list. BoringSSL and s2n are
not part of the supported TLS matrix because their Fluxheim integrations did
not provide the same complete policy, SNI, upstream TLS, and client-auth
coverage.
Explicit curve_preferences are capped at 16 entries, and explicit
cipher_suites are capped at 32 entries.
Supported curve names are X25519, CurveP256, and CurveP384.
X25519MLKEM768 is accepted by the config schema for future post-quantum
hybrid key exchange support, but the default rustls/ring backend rejects it
until Fluxheim offers a rustls crypto provider with post-quantum groups. OpenSSL
passes configured group names to the TLS library; runtime startup fails if the
installed library does not support a configured group.
The first global [[tls.certificates]] entry is the default downstream
certificate. Vhosts may provide their own static certificate for SNI selection:
[vhosts.tls]
enabled = true
[vhosts.tls.certificate]
cert_path = "tls/example-fullchain.pem"
key_path = "tls/example-key.pem"Fluxheim selects vhost certificates by SNI using the vhost hosts list,
including one-label wildcards such as *.api.example.test. The default rustls
build supports this through a rustls certificate resolver. Callback-capable TLS
backends use their native certificate callback APIs. TLS backends without SNI
certificate selection support reject vhost-specific certificates at startup
instead of silently serving the default certificate.
The global [[tls.certificates]] table is capped at 1024 certificate pairs.
Downstream client certificate authentication is configured globally for TLS listeners:
[tls.client_auth]
mode = "required" # "off", "optional", or "required"
ca_path = "/etc/fluxheim/tls/client-ca.pem"mode = "required" rejects TLS handshakes that do not present a certificate
chain trusted by ca_path. mode = "optional" asks for a client certificate
and verifies it when present, but still accepts clients without one. The CA
bundle path uses the same safe-path validation as other TLS files: no
parent-directory traversal, no symlinked path components, and no group- or
world-writable existing parent directory. The supported TLS matrix wires rustls
and OpenSSL listeners only.
Verified client-certificate identity can be forwarded explicitly with
request header templates such as {tls.client_cert_sha256}. Route decisions
based on certificate identity remain future work; do not rely on client-cert
attributes for routing or authorization unless a later release documents that
exact policy surface.
Release validation must still scan every release candidate with a TLS scanner before publishing a stable release.
[tls.fips] required = true is accepted by the config schema as a fail-closed
guard for FIPS/ISO-capable TLS builds. [tls.iso19790] required = true is an
ISO/IEC 19790 terminology alias for the same validated-provider enforcement
path. Neither setting is a blanket FIPS or ISO/IEC 19790 compliance claim. When
enabled, Fluxheim rejects non-NIST or unproven groups such as X25519 and
X25519MLKEM768, rejects non-approved cipher choices such as ChaCha20 suites,
and requires a backend-specific proof path.
Default builds fail closed because they do not contain a FIPS/ISO-capable proof
path. Builds compiled with tls-openssl-fips or the
tls-openssl-iso19790 alias may use backend = "openssl": runtime validation
then checks that the OpenSSL FIPS provider can be loaded and that an approved
cipher can be fetched with the fips=yes property query, enables OpenSSL
default FIPS properties for the process-default library context, verifies those
default properties, and checks that the default fetch path rejects a non-FIPS
cipher.
Builds compiled with tls-rustls-fips may use backend = "rustls": runtime
validation checks the rustls AWS-LC FIPS provider, TLS setup uses
rustls::crypto::default_fips_provider(), and listener startup rejects a
FIPS/ISO-required config unless ServerConfig::fips() reports true.
Operators still need the selected module's CMVP certificate, Security Policy,
provider/build configuration, platform evidence, and deployment records.
Fluxheim does not hardcode an OpenSSL provider path; OpenSSL provider discovery
follows the platform OpenSSL configuration and environment visible to the
process. The rustls/AWS-LC FIPS path follows rustls and aws-lc-fips-sys build
requirements, including CMake, Go, and a C compiler.
Use fluxheim crypto or fluxheim-config-tester --crypto to print compiled
TLS backend diagnostics. Use FIPS-Capable Deployments for the full
compliance boundary and roadmap. Do not treat a Cargo feature or this config
block alone as a FIPS compliance claim.
FIPS/ISO-required mode also applies internal-crypto guards outside the TLS
listener. Config validation rejects managed ACME ([tls.acme] enabled = true)
because ACME account key generation, JWS account signing, EAB handling,
outbound ACME HTTPS transport, and TLS-ALPN certificate generation are not yet
routed through the selected validated module. It allows
admin.enabled = true only in tls-openssl-fips or tls-rustls-fips builds,
where bearer-token HMAC is routed through OpenSSL FIPS or AWS-LC FIPS. It
rejects local disk-cache encryption because that path uses ring AES-GCM.
provider = "openbao-transit" cache encryption is allowed only through local
numeric loopback HTTP as an external evidence boundary, and OTLP metrics/traces
export is allowed only to numeric local http:// loopback collectors
(127.0.0.1 or [::1]) until outbound TLS can be provider-aligned. Disk cache
without encryption is still allowed, but Fluxheim logs a compliance warning
because cached response bodies are written at rest without a Fluxheim-managed
encryption boundary. Set tls.fips.require_disk_cache_encryption = true or
tls.iso19790.require_disk_cache_encryption = true to promote that warning to
a hard config error.
Request IDs and temporary object names are treated as non-secret operational
identifiers, not SSPs.
Check certificate storage permissions separately:
fluxheim --config path/to/fluxheim.toml --check-tls-storageOn Unix, private keys should be owner-only and ACME storage directories should be owner-only. The storage checker rejects symlinked certificate files, private key files, ACME EAB secret files, ACME storage directories, and paths below symlinked or group- or world-writable directories; mount or configure the real paths directly. If Fluxheim cannot inspect any TLS path prefix for symlinks, validation fails closed and reports the path as unreadable. Config validation also rejects static certificate paths, ACME storage paths, and ACME EAB secret files when their nearest existing parent directory is group- or world-writable. EAB secret files are checked with the same owner-only permission rule as private keys.
ACME config parsing, renewal planning, managed certificate storage paths, local
HTTP-01 challenge serving, and the local renewal execution contract exist.
Builds with acme-client can load or create issuer accounts and complete
HTTP-01 or rustls TLS-ALPN-01 orders through instant-acme. By default, the
runtime registers a background renewal service for configured ACME vhosts when
acme-client is compiled in. Set tls.acme.automation = "external" when a
systemd timer, container scheduler, or another supervisor runs acme-renew.
The background service observes managed certificate expiry and renews missing or
due certificates on the configured check interval. After successful renewal,
Fluxheim reloads downstream SNI certificate objects so new handshakes can use
the renewed files without a restart when the selected TLS backend exposes a
reloadable resolver or callback.
For reloadable SNI TLS backends, including the default rustls backend, missing
Fluxheim-managed ACME certificate files are a pending issuance state rather than
a startup failure. This lets operators add a new [vhosts.tls.acme] vhost while
keeping port 80 online for HTTP-01. Static certificates are different: if a
vhost points at operator-owned cert_path/key_path files, those files must
exist and pass storage checks before the listener starts.
You can also invoke renewal explicitly. Production packages include
fluxheim-acme, which can renew and then request live certificate activation
from the running gateway:
fluxheim-acme --config /etc/fluxheim/fluxheim.toml renew
fluxheim-acme --config /etc/fluxheim/fluxheim.toml reload
fluxheim --config /etc/fluxheim/fluxheim.toml acme-renewBy default the command renews missing or due certificates only. --force-renew
attempts every configured ACME vhost even when certificates are still valid;
use it sparingly because repeated forced renewals can hit issuer rate limits.
--all is accepted as a backward-compatible alias, but it prints a deprecation
warning and should not be used in new automation.
[tls.acme]
enabled = false
storage = "/var/lib/fluxheim/acme"
contact_email = "admin@example.test"
default_issuer = "letsencrypt"
challenge = "http-01"
automation = "background" # or "external" for fluxheim-acme.timer/container cron
[tls.acme.renewal]
enabled = true
renew_before_secs = 2592000
renew_after = 2026-06-01T00:00:00Z
check_interval_secs = 3600
retry_initial_secs = 300
retry_max_secs = 86400
reload_after_renewal = true
zero_downtime_reload = trueManaged ACME supports http-01 and, with the default rustls backend,
tls-alpn-01. HTTP-01 is easiest to operate when port 80 is reachable.
TLS-ALPN-01 is useful when port 443 is reachable and Fluxheim owns the TLS
listener; it requires server.tls_listen, tls.backend = "rustls", and an
ACME-managed or static fallback certificate for the listener. DNS-01 remains
future work because provider integrations need explicit secret handling and
record-cleanup behavior.
See examples/acme-http-01.toml for a minimal
HTTP-01 managed-certificate config. It can be used for first issuance with a
public HTTP listener only, or with a rustls SNI HTTPS listener whose managed
certificate is still pending. See
examples/acme-actalis.toml for the same flow
with file-backed External Account Binding secrets.
Built-in issuer names include letsencrypt, letsencrypt-staging,
actalis, google-trust-services, and google-trust-services-staging.
The custom [[tls.acme.issuers]] list is capped at 128 entries.
Actalis and Google Trust Services require External Account Binding. Their EAB
secret sources are configured through environment variables, files, or
credential names. Credential names are preferred for production because the same
config works with systemd credentials, Docker/Podman secrets, and Kubernetes
secret volumes without exposing values in process environments or container
metadata.
Example with systemd credentials:
[[tls.acme.issuers]]
name = "actalis"
directory_url = "https://acme-api.actalis.com/acme/directory"
[tls.acme.issuers.eab]
key_id_credential = "actalis-eab-kid"
hmac_key_credential = "actalis-eab-hmac-key"Example with container secrets:
[[tls.acme.issuers]]
name = "actalis"
directory_url = "https://acme-api.actalis.com/acme/directory"
[tls.acme.issuers.eab]
key_id_credential = "actalis-eab-kid"
hmac_key_credential = "actalis-eab-hmac-key"Google Trust Services production uses
https://dv.acme-v02.api.pki.goog/directory; staging uses
https://dv.acme-v02.test-api.pki.goog/directory. Fluxheim provides separate
built-in issuer names and default environment variables because Google EAB
secrets are single-use and environment-specific:
[tls.acme]
default_issuer = "google-trust-services"
# Production defaults:
# FLUXHEIM_GTS_EAB_KID
# FLUXHEIM_GTS_EAB_HMAC_KEY
#
# Staging defaults:
# FLUXHEIM_GTS_STAGING_EAB_KID
# FLUXHEIM_GTS_STAGING_EAB_HMAC_KEYEAB secret files are validated as sensitive files by
fluxheim --check-tls-storage: they must be regular files, must not be
symlinks, must not sit below symlinked or group- or world-writable parent directories, and
should be readable only by the Fluxheim process owner.
When [vhosts.tls.acme] is enabled, Fluxheim derives managed certificate files
below tls.acme.storage using a sanitized and hashed vhost directory:
<storage>/certificates/<safe-vhost-segment>/fullchain.pem
<storage>/certificates/<safe-vhost-segment>/privkey.pem
The exact directory segment is intentionally generated by Fluxheim rather than
accepted from config, so vhost names cannot create path traversal or hidden
filesystem locations.
Explicit vhosts.tls.acme.domains lists are capped at 64 domains. If
domains is omitted, Fluxheim derives the ACME names from the vhost hosts
list after excluding wildcard hosts.
ACME account credentials are stored under the same storage root with a sanitized and hashed issuer directory:
<storage>/accounts/<safe-issuer-segment>/credentials.json
These files contain account private key material. Fluxheim writes them with owner-only permissions on Unix, bounds their size, parses them as JSON, and rejects symlinked credential files.
When tls.acme.challenge = "http-01" and [vhosts.tls.acme] is enabled,
Fluxheim automatically serves /.well-known/acme-challenge/<token> for that
vhost from:
<storage>/http-01/<safe-vhost-segment>/<token>
Challenge tokens are restricted to one URL-safe path segment, challenge files
must be regular files, and oversized or control-character-containing responses
are rejected. If [vhosts.acme_challenge] is enabled, the explicit forwarding
helper takes precedence instead of the local managed challenge store.
When tls.acme.challenge = "tls-alpn-01" and [vhosts.tls.acme] is enabled,
Fluxheim generates temporary ACME challenge certificates below:
<storage>/tls-alpn-01/<safe-domain-segment>/fullchain.pem
<storage>/tls-alpn-01/<safe-domain-segment>/privkey.pem
These certificates are served only for TLS handshakes that offer the
acme-tls/1 ALPN protocol. Normal browser and proxy traffic continues to use
the configured static or ACME-managed vhost certificate selected by SNI.
Vhosts bind hostnames to per-site web, proxy, PHP-FPM, TLS, cache, and header settings.
TOML uses [[vhosts]] to start a new vhost. Every [vhosts.*] table that
follows belongs to that current vhost until the next [[vhosts]].
Vhost names and route names are capped at 128 bytes. These names are operator
labels used in logs, admin responses, and metrics; use DNS-style or short
service names rather than long descriptive strings.
# First vhost. The tables below belong to example.test.
[[vhosts]]
name = "example.test"
hosts = ["example.test", "www.example.test"]
max_request_body_bytes = "64MiB"
[vhosts.web]
root = "/srv/sites/example"
index_files = ["index.html"]
deny_dotfiles = true
[vhosts.proxy]
upstreams = ["127.0.0.1:3000", "127.0.0.1:3001"]
upstream_tls = false
[vhosts.headers.response.add]
access-control-allow-origin = "https://example.test"
[vhosts.access]
allow = ["198.51.100.0/24", "2001:db8:100::/48"]
deny = ["198.51.100.66"]
require_client_cert = false
allow_client_cert_sha256 = []
deny_client_cert_sha256 = []
[vhosts.rate_limit]
enabled = true
requests_per_second = 50
burst = 100
mode = "nodelay"
status = 429
reject_indeterminate = false
[vhosts.concurrency]
enabled = true
max_in_flight = 256
max_queue = 1024
queue_timeout_ms = 0
status = 503
# Second vhost. The tables below belong to api.example.test.
[[vhosts]]
name = "api.example.test"
hosts = ["api.example.test", "*.api.example.test"]
[vhosts.proxy]
upstreams = ["127.0.0.1:4000", "127.0.0.1:4001"]
upstream_tls = falseHostnames are normalized to lower case. Duplicate hosts are rejected. A single
left-most wildcard label is supported, for example *.api.example.test.
The config is capped at 1024 vhosts; each vhost may define up to 64 host
aliases and 256 routes.
max_request_body_bytes is optional on a vhost and overrides the global
server.limits.max_request_body_bytes for that host. Route-level
max_request_body_bytes still wins when a matching route sets its own limit.
[vhosts.access] and [vhosts.routes.access] provide the first 1.4 ACL
surface. IP rules accept exact IP addresses or CIDR ranges. deny entries win
first. When allow is non-empty, the effective client IP must match at least
one allow entry. The effective client IP is the direct peer address unless the
direct peer matches server.trusted_proxies; only then does Fluxheim inspect
X-Forwarded-For recursively. Client-certificate rules use the verified
downstream certificate SHA-256 fingerprint exposed by the TLS backend.
require_client_cert = true rejects requests without a verified client
certificate, deny_client_cert_sha256 rejects matching fingerprints, and
allow_client_cert_sha256 restricts access to the listed fingerprints when it
is non-empty. Fingerprints are 64-character hex SHA-256 values and are matched
case-insensitively. A vhost policy is evaluated before any matching route
policy, so route ACLs can further restrict traffic but cannot bypass a
vhost-level denial. With metrics enabled, ACL denials are counted by
fluxheim_edge_policy_events_total with bounded labels.
When Fluxheim is built with the optional geoip feature, [geoip] enables
local MMDB Geo-Context lookup for access policy:
[geoip]
enabled = true
fallback_enabled = true
[[geoip.databases]]
provider = "maxmind"
path = "/var/lib/fluxheim/geo/GeoLite2-Country.mmdb"
[[geoip.databases]]
provider = "circl-geo-open"
path = "/var/lib/fluxheim/geo/circl-country.mmdb"provider = "maxmind" covers MaxMind GeoIP2/GeoLite2 MMDB files.
provider = "circl-geo-open" covers European CIRCL Geo Open datasets when
supplied in MMDB-compatible form. Databases are ordered local fallbacks when
fallback_enabled = true; Fluxheim fills missing country or ASN fields from
later databases when possible.
Fluxheim does not download GeoIP databases in-process. Each MMDB file is capped
at 512 MiB at read time and each loaded GeoIP runtime is capped at 1 GiB total.
GeoIP update jobs should write and verify a replacement file, then atomically
rename it into place before reloading Fluxheim.
Vhost and route access policies can then use:
[vhosts.access]
allow_countries = ["SE", "NO", "DK", "FI"]
deny_countries = ["RU"]
allow_asns = [12552]
deny_asns = [64512]Country values must be uppercase ISO alpha-2 codes. ASN values are numeric and
must be greater than zero. Geo allow lists fail closed when no Geo-Context is
available; Geo deny lists deny only on a resolved match. See
docs/geoip.md for the feature boundary and operational update
pattern.
[vhosts.rate_limit] and [vhosts.routes.rate_limit] enable local token-bucket
request limiting. The first implementation keys by the same trusted-proxy-aware
client IP used for ACLs. requests_per_second sets the refill rate, burst
sets the bucket capacity, and status controls the rejection status. mode = "nodelay" consumes burst capacity immediately and rejects once the bucket is
empty. mode = "delay" reserves future tokens and sleeps the request up to
max_delay_ms; if the backlog would exceed that bounded delay budget, Fluxheim
rejects instead of queueing indefinitely. If burst is omitted or zero,
Fluxheim uses requests_per_second as the burst. State is bounded by
table_max_entries and stale entries are pruned after entry_ttl_secs. Vhost
limits are checked before route limits. If Fluxheim cannot determine an
effective client IP, the request uses one shared anonymous bucket for that
vhost or route; do not rely on anonymous-IP rate limiting as the only
protection for sensitive internal paths. Set reject_indeterminate = true to
reject those requests instead of placing them in the shared bucket.
With metrics enabled, delayed and rejected rate-limit decisions are counted by
fluxheim_edge_policy_events_total with bounded labels.
[vhosts.concurrency] and [vhosts.routes.concurrency] cap active in-flight
requests. They are local process limits, not distributed cluster limits.
max_in_flight sets the active request budget and status controls the
rejection status when the budget is exhausted. queue_timeout_ms = 0 rejects
immediately. A positive queue_timeout_ms lets Fluxheim wait briefly for a
permit before rejecting. max_queue caps waiting requests; 0 derives a
bounded default from max_in_flight. Vhost permits are acquired before route
permits and are released automatically when the request finishes.
With metrics enabled, concurrency-limit rejections are counted by
fluxheim_edge_policy_events_total with bounded labels.
Vhosts can also contain ordered route tables. Routes may optionally set
methods = ["GET", "HEAD"]; when present, the route only matches those
uppercase HTTP methods. Exact matches win first, then the longest prefix match,
then the first configured regex route, then one optional fallback route. Regex
routes require explicit global opt-in with
server.regex_enabled = true; configs that use path_regex without that flag
are rejected. Regex patterns use Rust's bounded regex engine and are checked
at config load time. Regex routes expose bounded request-header template
variables for migration patterns: {route.regex.0} is the full match,
{route.regex.1} through {route.regex.15} are numbered captures, and
{route.regex.name} reads a named capture such as (?P<name>...). Capture
values are capped before template rendering and are not used as metric labels.
A route must define exactly one matcher: path_exact, path_prefix,
path_regex, or fallback = true, and one action: redirect, proxy, web,
or php. Regex routes may also set rewrite_template to build a new upstream
path from bounded regex captures. rewrite_template is path-only, preserves the
original query string, rejects unsafe rendered paths, and cannot be combined
with strip_prefix or rewrite_prefix.
[[vhosts.routes]]
name = "chat"
path_prefix = "/chat/"
methods = ["GET", "HEAD"]
strip_prefix = "/chat/"
rewrite_prefix = "/backend/chat/"
max_request_body_bytes = "64MiB"
[vhosts.routes.proxy]
upstreams = ["127.0.0.1:6012"]
connect_timeout_secs = 5
read_timeout_secs = 600
send_timeout_secs = 600
[vhosts.routes.grpc]
enabled = false
require_content_type = true
[[vhosts.routes]]
name = "versioned-api"
path_regex = "^/api/v(?P<version>[0-9]+)/(?P<rest>.*)$"
rewrite_template = "/internal/v{route.regex.version}/{route.regex.rest}"
[vhosts.routes.headers.request.add]
x-api-version = "{route.regex.version}"
[vhosts.routes.proxy]
upstreams = ["127.0.0.1:6013"]
[vhosts.routes.cache]
enabled = true
status_header = "X-Cache-Status"
hide_response_headers = ["set-cookie"]
no_store_response_header_values = { x-app-cache = "private" }
bypass_request_header_values = { x-preview-mode = "1" }
bypass_cookie_values = { preview = "1" }
bypass_query_values = { mode = "private" }
bypass_query = false
status_ttls = { "200" = 3600, "302" = 3600, "404" = 60 }
stale_while_revalidate_secs = 30
stale_if_error_secs = 120
stale_if_error_on = ["connect", "timeout", "http-status"]
stale_if_error_statuses = [500, 502, 503, 504]
methods = ["GET", "HEAD"]
max_object_bytes = "32MiB"
[vhosts.routes.cache.memory]
enabled = true
max_size_bytes = "256MiB"
[[vhosts.routes.proxy.error_pages]]
status = 502
path = "/502.html"
[vhosts.routes.proxy.error_pages.web]
root = "/srv/fluxheim/errors"
[[vhosts.routes]]
name = "repo"
path_prefix = "/repo"
strip_prefix = "/repo"
[vhosts.routes.web]
root = "/srv/infra/repository/public"
index_files = ["repo.html", "index.html"]
[vhosts.routes.web.directory_listing]
enabled = true
exact_size = false
[[vhosts.routes]]
name = "php-app"
path_prefix = "/app/"
strip_prefix = "/app"
max_request_body_bytes = "64MiB"
[vhosts.routes.php]
preset = "wordpress"
enabled = true
runtime = "php-fpm"
root = "/srv/sites/php.example.test/public"
# Default false. When true, only the final php.root component may be a symlink;
# Fluxheim resolves it once at startup and still rejects symlinked parents.
resolve_root_symlink = false
# Optional: path visible inside a separate php-fpm container.
# When omitted, Fluxheim sends php.root as DOCUMENT_ROOT/SCRIPT_FILENAME.
fpm_root = "/app/public"
index = "index.php"
allowed_extensions = ["php"]
deny_path_prefixes = ["/wp-content/uploads/"]
# `wordpress` and `front-controller` fall back to index.php for missing paths.
# `strict` only executes explicit PHP scripts or directory PHP indexes.
try_files = "wordpress"
# Advanced migration switches; both default to true.
pass_request_headers = true
pass_request_body = true
# Optional override for CGI SERVER_PORT; otherwise Host port or scheme default is used.
server_port = 8443
request_timeout_secs = 30
max_request_body_bytes = "64MiB"
# Optional: spill larger PHP request bodies to disk before FastCGI dispatch.
request_body_spool_threshold_bytes = "4MiB"
request_body_spool_dir = "/var/lib/fluxheim/php-spool/example.test"
max_response_bytes = "64MiB"
max_response_header_bytes = "64KiB"
stderr_log = true
stderr_log_level = "warn"
stderr_max_bytes = "2KiB"
stderr_failure_patterns = ["PHP Fatal error:"]
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.routes.php.error_pages]]
status = 502
path = "/502.html"
[vhosts.routes.php.error_pages.web]
root = "/srv/errors"
index_files = ["index.html"]
[vhosts.routes.php.params]
APP_ENV = "production"
PHP_VALUE = "memory_limit=256M"
[vhosts.routes.php.fpm]
# Default mode. Fluxheim connects to an operator-managed php-fpm pool.
mode = "external"
tcp = "php-fpm:9000"
# Or use a private Unix socket:
# socket = "/run/php/php-fpm.sock"
# Or list multiple TCP endpoints for simple safe-method failover:
# tcp_upstreams = ["php-fpm-a:9000", "php-fpm-b:9000"]
connect_timeout_secs = 5
read_timeout_secs = 30
write_timeout_secs = 30
keepalive = true
pool_max_idle = 8
idle_timeout_secs = 60
# Conservative retry policy for connection failures before php-fpm returns data.
max_retries = 1
retry_timeout_secs = 5
retry_methods = ["GET", "HEAD", "OPTIONS"]
retry_invalid_response = false
retry_statuses = [500, 502, 503, 504]
# Managed mode keeps the same FastCGI/php-fpm protocol but lets Fluxheim start
# and supervise a private php-fpm master process. Do not set socket, tcp, or
# tcp_upstreams in managed mode; Fluxheim creates the socket itself.
# mode = "managed"
# php_fpm_binary = "/usr/sbin/php-fpm"
# socket_dir = "/run/fluxheim/php"
# workers = 4
# max_requests_per_worker = 1000
# process_manager = "static" # "static", "dynamic", or "ondemand"
# listen_backlog = 128
# Optional socket ownership controls for managed pools that drop privileges.
# Defaults to a private 0600 socket.
# listen_owner = "fluxheim"
# listen_group = "php"
# listen_mode = "0660" # "0600" or "0660"
#
# Dynamic pool sizing, only when process_manager = "dynamic":
# start_servers = 2
# min_spare_servers = 1
# max_spare_servers = 4
# max_spawn_rate = 8
#
# Ondemand pool sizing, only when process_manager = "ondemand":
# process_idle_timeout_secs = 10
#
# Request lifecycle and diagnostics:
# request_terminate_timeout_secs = 30
# request_terminate_timeout_track_finished = false
# request_slowlog_timeout_secs = 5
# request_slowlog_trace_depth = 20
# clear_env = true
# catch_workers_output = true
# decorate_workers_output = true
# session_save_path = "/run/fluxheim/php/session"
# upload_tmp_dir = "/run/fluxheim/php/upload"
#
# 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"
# Generated socket/config/pid/log files live under socket_dir. Forced process
# termination can leave stale files; remove them only while Fluxheim is stopped.
[vhosts.acme_challenge]
enabled = true
upstreams = ["host.containers.internal:8080"]
upstream_tls = false
connect_timeout_secs = 5
read_timeout_secs = 30
send_timeout_secs = 30
[vhosts.redirect]
enabled = true
to = "https://example.test{uri}"
status = 308strip_prefix is useful when a backend or alias root should receive /room
instead of /chat/room. Add rewrite_prefix when the stripped suffix should
be attached to an upstream path prefix, for example /chat/room?id=7 to
/backend/chat/room?id=7; it must be paired with strip_prefix and must be an
absolute safe path. Redirect targets must be absolute http:// or
https:// templates and may use {uri}, {path}, and {query}. Use
max_request_body_bytes on a route to narrow or expand the vhost or global
body limit for uploads handled by that route. Proxy actions accept
connect_timeout_secs, read_timeout_secs, and send_timeout_secs; route
proxy timeout values override the vhost/global proxy timeout values because the
route owns its own proxy action.
[vhosts.routes.grpc] is disabled by default and is valid only on proxy
routes. When enabled, the route proxy must set upstream_http_version = "http2"
or "http1-and-http2", cache must remain disabled for that route, and
require_content_type must stay true. This is a pass-through compatibility
policy: Fluxheim preserves HTTP/2 proxying behavior and rejects obvious
non-gRPC requests before forwarding, but it does not transcode gRPC-Web or JSON
to gRPC.
For PHP actions, max_request_body_bytes bounds the request sent to php-fpm
and max_response_bytes bounds the FastCGI STDOUT/STDERR bytes accepted from
php-fpm before Fluxheim rejects the response. Set php.request_body_spool_threshold_bytes with
php.request_body_spool_dir to spill larger request bodies to an owner-safe
temporary file before php-fpm dispatch. This keeps CONTENT_LENGTH exact for
FastCGI and lets retries replay the same upload without cloning a large memory
buffer; both spool settings must be configured together, and the spool file is
removed when the request completes. When php.max_request_body_bytes is set on
the same PHP action, the spool threshold must be lower than that body limit.
Existing spool paths must be directories, and existing directories must not be
group/world writable. Fluxheim rechecks those permissions after creating a
missing spool directory and before writing upload bodies.
php.fpm_root optionally rewrites DOCUMENT_ROOT,
SCRIPT_FILENAME, and PATH_TRANSLATED for separate php-fpm container
filesystem roots while Fluxheim still checks scripts under php.root.
php.resolve_root_symlink = true allows Caddy-style/current-release deploy
layouts where the final php.root path is a symlink. The default is false.
When enabled, Fluxheim resolves that final symlink at startup and still rejects
parent-directory traversal, symlinked parent directories, and unsafe writable
parents; script resolution and static offload continue to run under the
canonical target root.
php.max_response_header_bytes caps the CGI-style response header block before
body parsing and defaults to 64KiB.
php.deny_path_prefixes rejects PHP script execution for configured absolute
URI path prefixes before php-fpm is contacted. Use it for WordPress-style media
directories such as /wp-content/uploads/ where uploaded PHP files must never
execute. This is defense in depth on top of filesystem permissions; it blocks
Fluxheim's PHP execution path for matching URI prefixes even if a writable
upload directory accidentally contains a .php file. The list is capped at 128
prefixes.
php.allowed_extensions is capped at 16 plain extension names and rejects
case-insensitive duplicates.
php.preset = "wordpress" applies PHP-side WordPress migration defaults: it
uses the WordPress front-controller mode when try_files is otherwise unset and
adds deny prefixes for common upload/file directories such as
/wp-content/uploads/ and /files/.
php.try_files is a typed replacement for common try_files recipes:
front-controller keeps the default /index.php fallback, wordpress is an
explicit alias for WordPress-style front-controller sites, and strict behaves
like try_files $uri =404 for PHP execution while still allowing static files
to be served by [vhosts.web].
php.path_info defaults to disabled; set it to split only for applications
that expect safe trailing PATH_INFO after an explicit PHP script such as
/index.php/user/1. The older strict spelling is accepted as an alias for
split.
php.fpm.connect_timeout_secs caps connecting to php-fpm and is also bounded
by php.request_timeout_secs. read_timeout_secs and write_timeout_secs
currently act as stricter caps on the buffered FastCGI request phase; the
shortest of php.request_timeout_secs, php.fpm.read_timeout_secs, and
php.fpm.write_timeout_secs is used until the future streaming FastCGI path
can enforce separate per-direction timeouts.
php.pass_request_headers controls whether safe inbound request headers are
translated to CGI HTTP_* params. php.server_port can override CGI
SERVER_PORT; when omitted, Fluxheim uses an explicit port from the request
Host authority and otherwise falls back to 443 for TLS or 80 for
cleartext. php.pass_request_body controls whether the
HTTP request body is sent to php-fpm; when disabled, Fluxheim still drains and
limits the downstream body but sends CONTENT_LENGTH=0 and an empty FastCGI
stdin.
php.stderr_log controls whether FastCGI STDERR is written to Fluxheim logs.
php.stderr_log_level controls the emitted log level and accepts error,
warn, info, or debug; the default is warn.
php.stderr_max_bytes bounds each logged STDERR message and defaults to 2KiB;
larger output is sanitized and marked as truncated.
php.stderr_failure_patterns is a default-empty list of literal ASCII-safe
substrings. If any configured pattern appears in FastCGI STDERR, Fluxheim treats
the PHP response as invalid. With php.fpm.retry_invalid_response = true, this
can fail over safe methods to another php-fpm upstream for fatal PHP runtime
failures such as PHP Fatal error:. Matching STDERR is still sanitized,
bounded by php.stderr_max_bytes, and logged when php.stderr_log is enabled
before Fluxheim rejects the response. Up to 32 patterns are allowed, each 1 to
512 bytes without ASCII control characters.
php.hide_response_headers removes selected headers emitted by php-fpm before
Fluxheim applies the normal response header policy. This is useful for
NGINX-style migrations that hide X-Powered-By or other backend-only headers.
The list is case-insensitively deduplicated and capped at 64 header names.
php.ignore_origin_cache_headers removes PHP-generated Cache-Control,
Expires, and Pragma response headers after Fluxheim has consumed internal PHP
control headers. It defaults to false; use response header policy to set
replacement cache directives when needed.
Fluxheim consumes PHP X-Accel-Redirect and X-Sendfile headers for
PHP-assisted static offload instead of forwarding them to clients.
X-Accel-Redirect targets are internal URI paths resolved under php.root;
X-Sendfile targets are absolute filesystem paths resolved under php.root,
and are mapped from php.fpm_root for split-container layouts. Fluxheim refuses
to offload files with configured PHP script extensions.
Fluxheim also consumes PHP X-Accel-Expires control headers instead of
forwarding them to clients. Positive TTLs become normal Cache-Control and
Expires headers; responses with Set-Cookie use private cache directives,
and zero or past expiries become no-store, private.
Fluxheim always strips hop-by-hop php-fpm response headers such as
Connection, Transfer-Encoding, and headers named by Connection before it
frames the client response.
php.intercept_error_statuses is an explicit fastcgi_intercept_errors-style
status list. When PHP returns one of those 4xx/5xx statuses, Fluxheim discards
the PHP response body and sends a Fluxheim-generated error response instead.
It defaults to an empty list so PHP applications keep their normal error pages
unless the operator opts in. The status list is capped at the valid 400-599
error-status range and cannot contain duplicates.
[[vhosts.php.error_pages]] and [[vhosts.routes.php.error_pages]] are
internal static fallback pages for selected PHP statuses. A configured error
page also intercepts that status; if the static page cannot be served, Fluxheim
falls back to its generated error response. Use this for NGINX-style
fastcgi_intercept_errors migrations where PHP 502/503/504 responses should
never expose backend details. PHP error-page lists are capped at 64 entries and
cannot contain duplicate statuses.
When a slashless request resolves to a directory PHP index, Fluxheim returns a
canonical 308 redirect before executing the script, for example /blog to
/blog/ when /blog/index.php exists.
max_response_bytes defaults to 64MiB; set a smaller value on
memory-constrained or high-assurance edge nodes. Because PHP responses are
currently buffered, the configured value is capped at 64MiB. Use
X-Accel-Redirect or X-Sendfile for large files so Fluxheim can serve the
static asset path instead of buffering PHP output.
php.fpm.keepalive enables
FastCGI keep-connection reuse with an idle pool capped by
php.fpm.pool_max_idle; it is off by default for conservative compatibility.
Use either php.fpm.socket, php.fpm.tcp, or php.fpm.tcp_upstreams; the
endpoint modes are mutually exclusive. tcp_upstreams enables round-robin TCP
selection and conservative failover across configured php-fpm backends. The
tcp_upstreams list is capped at 64 entries and rejects duplicate authorities.
When enabled, stale idle entries older than php.fpm.idle_timeout_secs are
discarded before reuse. pool_max_idle must be between 1 and 1024 when
keepalive is enabled. php.fpm.max_retries defaults to 0; when set,
Fluxheim retries only connection failures and connect timeouts for configured
php.fpm.retry_methods before php-fpm has returned a response.
php.fpm.retry_timeout_secs optionally caps the total retry window for one PHP
request. With
tcp_upstreams, Fluxheim tries enough endpoints to cover the configured list
for safe methods even when max_retries = 0. Request timeouts are not retried
to avoid duplicating side effects. php.fpm.retry_invalid_response and
php.fpm.retry_statuses extend the same safe-method retry policy to malformed
FastCGI responses and selected PHP 5xx responses. They default to disabled;
configure them only for idempotent request methods where replaying the PHP
request is acceptable. php.fpm.retry_methods is capped at 16 uppercase safe-method
tokens and only accepts GET, HEAD, OPTIONS, and TRACE; php.fpm.retry_statuses is capped at the valid 500-599 server-error
status range.
[vhosts.php.params] or [vhosts.routes.php.params]
adds administrator-controlled FastCGI parameters such as APP_ENV or
PHP_VALUE; Fluxheim rejects unsafe names, control-character values, and core
CGI parameters that it owns, including SCRIPT_FILENAME, CONTENT_LENGTH,
HTTPS, and all HTTP_* request-header parameters. Custom parameter tables are capped at 128 entries;
each parameter name is capped at 128 bytes and each value at 16KiB. PHP_VALUE
and PHP_ADMIN_VALUE are powerful php-fpm controls; Fluxheim logs high-risk
warnings when they mention directives such as open_basedir,
disable_functions, allow_url_include, or allow_url_fopen, and logs an
error-level warning if PHP_ADMIN_VALUE overrides disable_functions.
[vhosts.routes.cache] is optional. When present, it replaces the vhost cache
policy for that matched route only. Routes without a cache block continue to use
the vhost cache policy, so selective caches can cover paths such as /assets/,
/avatars/, or repository image content without caching every backend response.
When global [server.https_redirect] is enabled, non-redirect routes are
redirected on cleartext requests by default. [vhosts.acme_challenge] creates
the standard HTTP-01 /.well-known/acme-challenge/ proxy route and exempts only
that path. Advanced route configs can still use https_redirect_exempt = true
for deliberate non-ACME cleartext exceptions.
Use either upstream = "host:port" or upstreams = ["host:port"]; do not set
both. The helper accepts the same upstream_tls and upstream timeout fields as
normal proxy actions. ACME challenge upstream lists are capped at 64 entries
and reject duplicates case-insensitively.
[vhosts.redirect] creates a fallback redirect route for the whole vhost. It is
intended for canonical-host vhosts such as www to apex redirects. Do not
combine it with an explicit fallback route on the same vhost.
Static route actions support directory listing for repository-style file roots.
Listings are disabled by default, index files still win when present, dotfiles
remain denied when deny_dotfiles = true, symlink entries are skipped, and the
generated HTML is sent with cache-control: private, no-store. Keep
exact_size = false for large directories when approximate display is enough.
local_time = true renders listing modification times with the server's local
UTC offset; otherwise listings use GMT HTTP-date timestamps.
For production readability, prefer one vhost per file in a split config directory. See Vhost Config Guide and Gateway Recipes.
Build:
cargo build --no-default-features --features profile-privacyUse examples/privacy.toml as the baseline config. It disables access logging,
request IDs, metrics, cache, and client-IP forwarding headers.
Invalid privacy combinations are rejected by release checks:
privacy-modewithcacheprivacy-modewithmetrics
Before packaging a custom feature set, validate it:
scripts/validate-features.sh proxy,web,tls-rustlsThis catches unsupported combinations before Cargo starts compiling Pingora. See Feature Matrix for the complete feature/profile list.