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.
When multiple fragments set server.trusted_proxies, Fluxheim extends the
trusted-proxy list and deduplicates exact entries instead of replacing the
earlier list. This keeps a later fragment from silently discarding the main
config's client-IP trust boundary.
When multiple fragments set [proxy], Fluxheim applies field-level merge
semantics. A later proxy fragment that sets a timeout or buffer setting does
not replace the earlier upstream, TLS verification, authentication, mirror, or
load-balancer policy.
The same field-level merge model applies to [admin], [compression],
[cache], [cache_purger], [web], and [stream]. Later fragments can
override fields they explicitly set without clearing previously configured
admin authentication, resource limits, cache encryption, static-file safety
policy, or stream routes.
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. On Unix, each config file, config directory, conf.d directory, and
every existing ancestor must be owned by root or the Fluxheim service user and
must not be group- or world-writable. Each TOML file is opened without following
a final symlink, its checked path identity must match the opened descriptor, and
in-place changes during the bounded read are rejected. 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.
Rootless Podman deployments must ensure bind-mounted config ownership maps to
the image runtime UID/GID 65532:65532; host-user or host-root ownership can
appear as an unrelated UID inside the user namespace even on a read-only mount.
See Rootless Config Ownership
for explicit podman unshare chown, permission, :U, and verification steps.
[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"
max_buffered_request_body_bytes = "1GiB"
[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:
max_request_body_byteslimits one buffered downstream request body.max_buffered_request_body_bytesis the process-wide admission budget shared by HTTP/1 and HTTP/2; it defaults to1GiBand must be at least as large as every configured server, vhost, and route body limit. Fluxheim reserves an exact validatedContent-Length, rounded to 64 KiB admission units, and grows unknown-length HTTP/1 chunked and HTTP/2 reservations incrementally before extending their buffers. It returns503withRetry-After: 1while the aggregate budget remains occupied. HTTP/1 body bytes are moved without constructing a second full-body copy and retained in one owned, full-capacity-cleared allocation; the same allocation becomes the owned H2 DATA source when an HTTP/2 upstream is selected. The admission reservation is owned by that sanitizing body allocation, so queued H2 DATA slices retain their process-budget charge until the final transport reference is released. Secure HTTP/1 chunked and unknown-length HTTP/2 body growth temporarily admits both old and replacement capacities before reallocating, and consumed connection buffers do not retain uploaded bodies after request dispatch. Fluxheim does not automatically retry or fail over a request with a non-empty body. Tune the budget to available service memory.listenandtls_listencannot both be empty unless[stream].enabled = truesupplies dedicated TCP stream listeners.server.process.upstream_keepalive_pool_sizeis a per-native-upstream idle socket cap. Size it against the process file-descriptor budget; total idle upstream sockets can be the sum of this cap across native proxy entries. Values above 16384 are rejected.- 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 a missing HTTP/1.0 host or an unknown valid host. Malformed authority syntax and a missing HTTP/1.1Hostalways fail at the protocol boundary. Set strict routing totruein hardened multi-tenant deployments to reject missing host identity with400and unknown hosts with421.- Host names are normalized to lowercase and reject percent signs, empty labels
such as consecutive dots, leading/trailing label hyphens, overlong labels,
and numeric-only final labels. Single-label internal names such as
localhostremain valid. - 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. Entries must be concrete IP addresses or bounded CIDR ranges; catch-all and near-global trust scopes such as0.0.0.0/0, IPv4 prefixes broader than/8,::/0, IPv6 prefixes broader than/29, and unspecified addresses are rejected.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, oversized v1/v2 input, or a v2 payload whose byte length differs from the frame header.[server.process]maps safe process settings into Fluxheim's native runtime process plan. Changes to these values require a process upgrade, not a live snapshot reload. Keepthreadsconservative in containers because worker threads and listener tasks are process-level resources.- On process shutdown,
grace_period_secondsdelays the drain request when it is configured. Fluxheim then drops native accept sockets while established HTTP, HTTPS, HTTP/2, and local HTTP connections drain.graceful_shutdown_timeout_secondsbounds that drain; omitting it uses an effective 30-second timeout. The supervisor stop timeout must exceed the sum of the configured grace and effective drain timeout. 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
proxy_header_timeout_secs = 10
# max_connection_secs = 3600
max_connection_bytes = 1073741824
max_connections = 1024
downstream_proxy_protocol = "off" # "off", "v1", or "v2"
trusted_proxies = []
allow_sources = []
deny_sources = []
upstream_proxy_protocol = "off" # "off", "v1", or "v2"
upstream_tls = false
upstream_dns_allow_private_addresses = 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 value from 1 through 1000 for eachupstreamsentry; the aggregate must not exceed 65535. Config validation and the runtime selector both enforce these limits with checked accumulation.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. Both copy directions retain their read/write state across dispatcher polls, so simultaneous backpressured traffic cannot cancel a partial write. Every successful partial write refreshes the deadline and its forwarded plaintext range is cleared from the 16 KiB direction buffer immediately; the complete buffer is cleared when the direction exits.proxy_header_timeout_secsis one absolute deadline for receiving the complete downstream PROXY v1 or v2 preamble. It defaults to10, must be non-zero, and cannot exceed60. Individual bytes do not refresh it. The setting has no effect whiledownstream_proxy_protocol = "off".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_connectionscaps concurrent accepted connections before connecting upstream and defaults to1024. Settingmax_connections = 0is an explicit unlimited override for private or otherwise externally protected stream routes; public listeners should keep a non-zero cap sized to the backend.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.allow_sourcesanddeny_sourcesare route-local stream source policies. Entries are IP addresses or CIDR ranges.deny_sourceswins overallow_sources. Whenallow_sourcesis non-empty, clients without a source address or outside the allow list are rejected before Fluxheim connects upstream. With downstream PROXY receive enabled, the policy evaluates the trusted PROXY client address; otherwise it evaluates the direct TCP peer. HTTP access, auth subrequest, rate-limit, and geo policies do not apply to raw TCP stream routes.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. Hostname upstreams are resolved on connection setup. By default, Fluxheim rejects hostname DNS answers that resolve only to private, loopback, link-local, multicast, broadcast, documentation, or unspecified addresses to reduce DNS-rebinding pivots. IANA special-purpose translation, transition, discard-only, benchmarking, site-local, and SRv6 SID prefixes are rejected as well. IPv4-mapped and legacy IPv4-compatible IPv6 answers are normalized and evaluated by the IPv4 policy before selection. Setupstream_dns_allow_private_addresses = trueonly for routes whose hostname upstreams are intentionally resolved by trusted internal DNS. IP-literal upstreams remain explicit and are not blocked by this DNS guard.upstream_sniis optional for hostname upstreams; when unset Fluxheim derives SNI from the selected upstream host. IP-literal upstreams withupstream_tls = trueand certificate verification enabled require explicitupstream_sni, because an IP address does not provide a DNS hostname for certificate verification.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.21 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 = 4096
max_sessions_per_source = 64
max_responses_per_source_per_second = 256
passive_health_enabled = true
passive_health_failures = 3
passive_health_ejection_secs = 10modeis 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.max_sessions_per_sourcedefaults to64. It limits concurrent in-flight datagram sessions per source IP and is released when the datagram finishes.0disables the per-source cap. Non-zero values are capped at 1000000.max_responses_per_source_per_seconddefaults to256for response modes. It limits downstream responses per source IP per one-second window, and0disables the response-rate cap. Fluxheim prunes old windows and bounds the tracked source table by the larger of the route/session caps and an internal 4096-source floor.passive_health_enableddefaults totrue. In request/response modes, consecutive upstream send/receive failures eject a member from selection forpassive_health_ejection_secsseconds oncepassive_health_failuresis reached. A successful exchange clears the member failure count and ejection state. Local downstream drops, including per-source response-rate limiting and oversized upstream responses rejected by Fluxheim's datagram cap, do not count as upstream passive-health failures. If all members are ejected, Fluxheim falls back to trying the selected member so a full pool outage does not become a permanent local dead end.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. Fluxheim logs a security warning when adns-load-balanceroute listens on a non-loopback address, but this is still an operator decision during beta.- In rootless container deployments, prefer binding UDP beta routes to an
explicit host/container IP and publish only the required UDP port. Avoid
broad
0.0.0.0:portor[::]:portbindings until the route has ingress filtering, source pressure limits, and operational metrics monitored. - UDP routes expose Prometheus metrics when the
metricsfeature is compiled:fluxheim_udp_datagrams_total,fluxheim_udp_drops_total, andfluxheim_udp_active_sessions. The admin API exposes configured UDP route status atGET /_fluxheim/udp/statuswhen both admin andudp-proxyare compiled in. 1.5.21does 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"
snapshot_integrity_key_file = "/etc/fluxheim/snapshot-integrity.key"
[admin.transport]
mode = "local_only"
[admin.ops_socket]
enabled = false
path = "/run/fluxheim/fluxheim-ops.sock"
mode = "0600"
require_bearer_token = false
[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.
snapshot_integrity_key_file is optional; when set, snapshot creation writes
HMAC-SHA-256 manifests and rollback requires a valid manifest. Keep the key
outside the store in a private regular file containing 32..=4096 bytes. On
Unix, the key and every snapshot state file must have no group or other access
bits; Fluxheim rejects them otherwise. Snapshot HMAC and SHA-256 operations use
the selected Ring, OpenSSL-FIPS, or AWS-LC-FIPS internal crypto provider.
token_file, snapshot_store, and the integrity key path 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. Configure snapshot_store as a dedicated
directory, never a filesystem root or shared system/application directory. An
existing store must already be owned by the service identity with mode 0700;
Fluxheim rejects it instead of changing its permissions. When the final store
directory is absent, its immediate trusted parent must already exist and
Fluxheim creates the dedicated store as 0700.
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. By default, the Unix socket's filesystem permissions are the
trust boundary. Set require_bearer_token = true to require the same
Authorization: Bearer ... token as the TCP admin listener for read-only ops
socket status requests. GET /_fluxheim/snapshots always requires the bearer
token on the ops socket because snapshot IDs and messages expose deployment
change history.
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 load-balancer implementation, 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.
When compiled with wasm, GET /_fluxheim/status includes a read-only wasm
object for the validation-stage plugin registry. It reports whether [wasm] is
enabled, whether preview ABIs are allowed, plugin root/plugin/attachment counts,
the process-wide max_total_concurrent_executions ceiling, the preview-specific
max_total_preview_concurrent_executions ceiling, the cache-specific
max_total_cache_concurrent_executions ceiling, plugin names, plugin paths,
configured expected SHA-256 digests, declared ABI and host-call namespace,
phases, fail mode, attachment priority, and whether each plugin or attachment
overrides the default admission/limit policy. Runtime loaded plugin hashes are
added by later 1.7.x hook releases. All Wasm active-execution and queued
admission values are bounded to 0..=256, with active limits required to be
non-zero. Runtime acquisition proceeds from attachment and plugin scopes toward
the process-wide scope so a narrow backlog cannot consume global permits.
Sandbox limits also have crate-enforced ceilings: max_module_bytes is at most
16 MiB, max_compiled_artifact_bytes at most 256 MiB, max_memory_bytes at
most 256 MiB, max_table_elements at most 100000, fuel at most 100000000,
timeout_ms at most 5000, and compile_timeout_ms at most 10000. These are
configuration ceilings, not recommended defaults. The compiled-artifact limit
is checked before a module enters the live registry. Native Wasmtime
compilation cannot be preempted safely in-process, so compile_timeout_ms is a
result deadline: an over-deadline compile completes synchronously, releases its
global slot, and is then rejected. Fluxheim never leaves detached compiler
threads running after returning a timeout. Deployments that require hard
compiler cancellation must isolate the Fluxheim startup/reload process with OS
CPU and memory limits; killable per-plugin workers require a future
process-isolated Wasm execution architecture.
1.7.1 enables the first live
native HTTP/1 request-path hook family: access-decision. The current preview
ABI calls fluxheim_access_decision() -> i32, where 0 continues the chain,
1 allows/continues, and 2 denies with 403. Built-in Fluxheim access
policy runs first and cannot be overridden by Wasm.
1.7.2 adds live native HTTP/1 request-headers and response-headers
hooks. These hooks use the fluxheim_policy_v1 host-call namespace with
bounded integer IDs for coarse request context and allow-listed synthetic
header mutations. They do not expose raw request headers, response headers,
Authorization, Cookie, Set-Cookie, request bodies, private keys, admin
credentials, filesystem, network, or process APIs.
1.7.3 adds live native HTTP/1 route-decision hooks. The first preview ABI
supports continue, deny, and configured canary or mirror branch
selection. Fluxheim accepts a selected branch only when it maps to an existing
configured route that still matches the current request method and path;
unavailable branches fail closed with 503. The mirror branch only selects a
configured matching route named mirror; its normal [proxy.mirror] policy
still controls shadow target, sampling, method allow-list, timeouts, and
in-flight limits. A selected route may use normal native load balancing;
Fluxheim still chooses the backend through the configured proxy.load_balance
policy. A selected route may also use normal configured load-balancer
persistence; plugins do not provide arbitrary persistence keys in this preview.
1.7.4 adds the first live native HTTP/1 cache-policy hooks: cache-lookup
and cache-store. The cache-lookup preview ABI calls
fluxheim_cache_lookup() -> i32, where 0 continues normal cache
lookup/storage, 1 passes the request through origin without lookup or
storage, 2 bypasses cache lookup/storage, and 3 denies with 403. The
hook runs before native proxy-cache slice lookup, normal lookup, peer-fill,
request collapsing, origin-fill protection, and store admission. The
cache-store preview ABI calls fluxheim_cache_store() -> i32 after an
origin response and before memory/disk cache writes; 0 continues normal
storage, 1 serves the origin response but skips storage, and 2 denies with
403. Built-in access, rate-limit, route selection, and header-policy
controls still run in their normal order. Cache hooks use the separate
wasm.max_total_cache_concurrent_executions process-wide admission ceiling so
hot cache-policy routes cannot starve security-decision hooks that use
wasm.max_total_concurrent_executions. Cache-store hook chains use
most-restrictive-wins aggregation: a later deny still runs and wins after an
earlier skip. 1.7.5 adds the first bounded cache-key mutation host call:
plugins can read the symbolic X-Device-Class context and append either the
fixed wasm-device-class=mobile or wasm-device-class=desktop component to
the cache key. The host rejects duplicate components, unknown IDs, and
component counts above the hard cap across the full hook chain. Wasm-selected
components are included in complete-object, single-range, and fixed-slice
range-cache keys. 1.7.5 also adds fixed-ID cache-store TTL/tag/header
metadata: set_cache_ttl(1, 0), set_cache_ttl(2, 0),
add_cache_tag(1, 0), add_cache_tag(2, 0),
set_cache_store_header(1, 1), and set_cache_store_header(1, 2). The stored
header call only writes x-fluxheim-cache-policy with fixed values to the
cached object; the immediate origin MISS response is not rewritten. Unknown
TTL/tag/header IDs, duplicate TTL overrides, duplicate stored-header
mutations, and mutation counts above the hard caps fail through the plugin fail
mode. Tag and stored-header mutation caps are enforced independently so one
metadata family cannot suppress the other. The current cache hooks still do not
expose raw headers, request bodies, arbitrary cache-key bytes, arbitrary TTLs,
arbitrary tag strings, arbitrary stored response headers, or response-store
body mutation; those stay staged behind later bounded cache-policy ABIs.
1.7.7 adds the opt-in wasm-proxy-abi compatibility preview boundary. A
plugin may use the proxy preview host-call namespace only when all of these are
true: the binary was built with wasm-proxy-abi, [wasm].allow_preview_abi = true, the plugin declares abi = "proxy-wasm-preview", and the plugin declares
host_call_namespace = "proxy-wasm-preview". The preview namespace is separate
from fluxheim-policy-v1; mismatched ABI/namespace pairs are rejected during
config validation. Unsupported preview host calls fail deterministically through
the plugin fail mode.
[wasm]
enabled = true
allow_preview_abi = true
max_total_preview_concurrent_executions = 16
[[wasm.plugins]]
name = "proxy_preview"
path = "/etc/fluxheim/plugins/proxy-preview.wasm"
abi = "proxy-wasm-preview"
host_call_namespace = "proxy-wasm-preview"
phases = ["access-decision"]1.7.8 adds the separate opt-in wasm-wasi capability preview for WASI
Preview 1 core modules. The binary must be built with wasm-wasi, preview ABIs
must be allowed, and the ABI/namespace pair must both be wasi-preview.
Capabilities default to false and are part of the compiled-module identity.
Only clock and randomness imports are currently reviewable grants:
[wasm]
enabled = true
allow_preview_abi = true
[[wasm.plugins]]
name = "wasi_random_policy"
path = "/etc/fluxheim/plugins/wasi-random-policy.wasm"
sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
abi = "wasi-preview"
host_call_namespace = "wasi-preview"
phases = ["access-decision"]
fail_mode = "fail-closed"
[wasm.plugins.wasi]
clocks = false
randomness = trueFluxheim checks every declared wasi_snapshot_preview1 import before module
instantiation. clock_res_get and clock_time_get require clocks = true;
random_get requires randomness = true. Environment and arguments,
inherited stdin/stdout/stderr, filesystem descriptors and paths,
sockets/network, polling, and process-exit imports are not configurable in this
preview and fail closed. WASI plugins currently support only
access-decision, do not receive request bodies, and remain subject to the
normal Wasm digest pin, fuel, memory, table, timeout, admission, and fail-mode
controls. Each random_get call is capped at 4096 bytes; a larger request
traps and follows the plugin fail mode.
Preview-namespace access decisions use a separate process-wide admission pool
configured by wasm.max_total_preview_concurrent_executions (default and hard
maximum 32) and a separate 32-slot blocking-work class. wasi-preview and
proxy-wasm-preview therefore cannot consume the native
fluxheim-policy-v1 admission or blocking-work capacity.
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. It follows
admin.ops_socket.require_bearer_token; unlike status endpoints,
/_fluxheim/snapshots always requires bearer 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. Source-specific lockouts are enforced before
credential validation. A valid token and, when configured, valid client
certificate can still use the admin API during a global invalid-attempt
lockout; the global lockout continues to reject invalid credentials. This
prevents remote failures from disabling incident response. Keep the local
owner-restricted [admin.ops_socket] available as a recovery/status channel
for deployments with strict operational requirements. 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. The native metrics handler only
serves GET/HEAD /metrics and can enforce a bearer token from
metrics.token_file. Fluxheim validates the configured token source at
startup, and the native metrics listener enforces the token before returning
Prometheus output. Keep the listener bound to loopback or a trusted management
network even when token authentication is enabled.
metrics.token_env is parsed but rejected because Rust 2024 treats process
environment mutation as unsafe and Fluxheim forbids unsafe code in the root
crate. Use metrics.token_file instead. Token files can be permissioned,
mounted from a secret store, and rotated without exposing the token in the
process environment.
[metrics]
enabled = false
listen = "127.0.0.1:9091"
require_loopback = true
# Optional native metrics bearer-token source.
# token_file = "/run/secrets/fluxheim-metrics-token"
[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. Runtime log-file open also rejects symlinked
path components before creating or appending the file, so restart and rotation
paths apply the same trust boundary as config validation. On Unix, file logs
must use a dedicated log directory and are rejected when any existing parent is
not owned by root or the service user, or 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.hardening]
# off, baseline, or cross-origin-isolated
profile = "off"
[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. IPv4-mapped IPv6 socket addresses such as
::ffff:192.0.2.10 are normalized to IPv4 before trusted-proxy, access-policy,
rate-limit, and GeoIP decisions, so IPv4 CIDR rules apply consistently on
dual-stack listeners.
The inbound identity denylist includes Client-IP, X-Client-IP,
X-Forwarded-For, X-Real-IP, Forwarded, common CDN identity fields,
X-Envoy-External-Address, X-Original-Forwarded-For, X-Azure-ClientIP,
Fly-Client-IP, X-ProxyUser-IP, X-Forwarded-Client-Cert, and the generated
forwarded host/protocol fields. Malformed trusted
X-Forwarded-For hops, including repeated or unbalanced quotes, invalidate the
whole chain and fall back to the directly observed peer. Add deployment-specific
identity headers to headers.request.remove when an upstream or edge product
uses a name outside Fluxheim's built-in list.
Warning: setting strip_inbound_client_ip_headers = false together with
x_forwarded_host = false or x_forwarded_proto = false allows
client-supplied X-Forwarded-Host or X-Forwarded-Proto values to reach the
upstream unchanged. Only use that combination when the upstream validates those
headers independently.
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. Static header values must be
non-empty after trimming and cannot contain HTTP control bytes, including
horizontal tab, so one config works safely for both HTTP/1.x and HTTP/2
upstreams. Dynamic template variables strip control characters before insertion.
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.
Response hardening profiles are opt-in and default to off, preserving existing
site behavior. baseline removes the Server banner and emits a conservative
Permissions-Policy plus X-Permitted-Cross-Domain-Policies: none.
cross-origin-isolated adds Cross-Origin-Opener-Policy: same-origin,
Cross-Origin-Resource-Policy: same-origin, and
Cross-Origin-Embedder-Policy: require-corp. Cross-origin isolation can break
third-party scripts, fonts, images, OAuth flows, and embedded resources; enable
it only after testing the complete site. Neither profile enables HSTS or CSP,
because those policies require deployment-specific TLS, asset, and reporting
decisions.
Typed response fields and explicit response mutations override profile values:
[headers.response]
content_security_policy_report_only = "default-src 'self'; report-to csp"
cross_origin_opener_policy = "same-origin-allow-popups"
cross_origin_resource_policy = "same-site"
cross_origin_embedder_policy = "credentialless"
x_permitted_cross_domain_policies = "none"
[headers.response.permissions_policy]
enabled = true
# deny-sensitive or deny-all
profile = "deny-sensitive"
[headers.response.reporting_endpoints]
csp = "https://reports.example.com/csp"Reporting endpoints are limited to 16 HTTPS URLs, use lowercase RFC 9651
dictionary keys, and have a bounded aggregate serialized size. Use report-only
CSP before enforcement, keep the endpoint on trusted infrastructure, and bound
its retention and ingestion independently. The generic response mutation layer
remains available for deliberately site-specific policies. For example,
Clear-Site-Data should be limited to logout or account-reset routes, never a
global header:
[[vhosts.routes]]
name = "logout"
path_exact = "/logout"
[vhosts.routes.headers.response.add]
clear-site-data = "\"cache\", \"cookies\", \"storage\""Fluxheim does not add obsolete X-XSS-Protection, Expect-CT, HPKP, or
Feature-Policy headers. Use CSP, HSTS, and Permissions-Policy instead.
Standards-based cache, proxy, and digest fields are opt-in and disabled by default. They are generated from runtime outcomes and final response bytes; they are not aliases for static response-header mutation.
[headers.response.metadata]
# Required when cache_status or proxy_status is enabled. This is a public
# Structured Fields token identifying this Fluxheim deployment, not an origin.
identifier = "edge-gateway"
cache_status = true
proxy_status = true
content_digest = true
repr_digest = trueThe same table can be overlaid at [vhosts.headers.response.metadata] and
[vhosts.routes.headers.response.metadata]. The identifier is limited to 64
bytes and must use HTTP Structured Fields token grammar. Fluxheim rejects a
policy that enables cache_status or proxy_status without a valid inherited
identifier.
cache_status appends an RFC 9211 Cache-Status member derived from the real
cache result. The member reports only bounded outcomes such as hit, URI miss,
stored, stale forwarding, or bypass. It does not expose cache keys, internal
tiers, admission reasons, or origin topology. proxy_status appends an RFC
9209 Proxy-Status member only for a Fluxheim-generated proxy failure, using a
standard low-cardinality error token. Backend addresses, DNS names,
certificate details, and raw error strings are never included. Existing
origin members remain visible as separate list members, so enable these fields
only when exposing cache/proxy behavior is acceptable for the route.
content_digest emits RFC 9530 Content-Digest with SHA-256 over the final
HTTP message content after any Fluxheim compression. repr_digest emits
RFC 9530 Repr-Digest only when Fluxheim has the complete selected
representation: currently a complete GET response with status 200, no
Content-Range, and a body consistent with the declared content length.
Fluxheim deliberately suppresses Repr-Digest for HEAD, 206, 304, and
other responses where the full representation is unavailable rather than
guessing it. Content-Digest on bodyless HEAD and 304 responses covers the
empty message content; on 206 it covers only the returned range. If Fluxheim
compresses a response while digest generation is disabled, it removes any
origin digest fields that would describe the pre-compression bytes.
The current native response path is bounded and buffered. Digest generation hashes that final buffer without making a second body copy; unbuffered digest trailers are not currently supported. Digest fields provide transport integrity metadata, not authentication or authorization.
CORS is disabled by default and must use the dedicated request-aware policy.
Static Access-Control-Allow-* response strings cannot safely implement origin
allowlists or preflight behavior.
[headers.cors]
enabled = true
allow_origins = ["https://app.example.com"]
allow_methods = ["GET", "HEAD", "POST"]
allow_headers = ["authorization", "content-type"]
expose_headers = ["x-request-id"]
allow_credentials = true
max_age_secs = 600The same fields can be overlaid at [vhosts.headers.cors] and
[vhosts.routes.headers.cors]. Fluxheim validates the final inherited policy,
rejects duplicate origins/methods/headers, rejects non-origin URLs, and never
allows wildcard origin * with credentials. Valid preflights are answered
locally with 204; denied preflights receive 403 and do not reach the origin.
Fluxheim owns the resulting CORS headers, replaces unsafe upstream values, and
manages Vary: Origin, Vary: Access-Control-Request-Method, and
Vary: Access-Control-Request-Headers. Response compression separately manages
Vary: Accept-Encoding. Actual responses receive CORS authorization headers
only when both the request origin and request method are allowed. CORS controls
whether browser JavaScript may read a response; it is not CSRF protection and
must never replace route access policy, authentication, authorization, or
same-site cookie controls.
Generated rate-limit and concurrency-limit responses include
Retry-After: 1. Temporary PHP-FPM and ACME blocking-work saturation responses
also include bounded retry guidance. Fluxheim does not add retry guidance to
permanent policy, configuration, or Wasm failures.
Fluxheim strips X-Powered-By by default. The native runtime preserves an
origin Server header but does not synthesize one when the origin omits it.
The opt-in hardening profiles remove the origin banner. Operators can also
remove it explicitly with remove = ["server"] or replace it 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. Quoted Refresh URLs such as url="https://backend/" and
url='https://backend/' are matched against the unquoted URL and keep their
quote style after rewriting. 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. When from is an absolute
URL prefix, Fluxheim only rewrites matches that end at the URL boundary or are
followed by /, ?, or #; this prevents authority-continuation values such
as http://backend.internal@evil.example/ from matching a backend origin
prefix.
[[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"
# upstream_h2c_upgrade = false
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"
max_in_flight = 64
[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_read_timeout_secs = 60
downstream_write_timeout_secs = 30
downstream_total_response_timeout_secs = 300
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
min_healthy_backends = 1
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. Hostname authorities use the
same normalized DNS-label checks as incoming Host headers.
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.
Resolved DNS addresses in private, loopback, link-local, multicast, reserved,
documentation, metadata, or unspecified ranges are rejected by default so a
compromised or attacker-controlled DNS record cannot silently rebind a public
service name to an internal backend. Set
upstream_dns_allow_private_backends = true only for trusted service-discovery
DNS zones that intentionally resolve to private service-network addresses.
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.
Returned IP-literal backends in private, loopback, link-local, multicast,
reserved, documentation, metadata, IPv4-mapped IPv6, IPv4-compatible IPv6,
6to4, or Teredo-embedded private ranges are rejected by default. Set
upstreams_http_allow_private_backends = true only when the configured
discovery endpoint is trusted to return private service-network members and the
route is intended to reach those networks.
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. IP-addressed upstreams
with upstream_tls = true and certificate verification enabled require explicit
upstream_sni; otherwise the upstream TLS connector cannot perform normal
certificate verification. 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, the PROXY protocol unknown-port value, because forwarded headers do
not carry the original source port; 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 prior knowledge.
upstream_h2c_upgrade defaults to false and is only valid for plaintext
upstream_http_version = "http1-and-http2" origins. When explicitly enabled,
Fluxheim first sends a bounded HTTP/1.1 OPTIONS * h2c Upgrade probe and uses
HTTP/2 only if the origin answers 101 Switching Protocols with Upgrade: h2c; otherwise it opens a fresh HTTP/1.1 connection. This compatibility mode
is not enabled by default because cleartext origins have no ALPN negotiation
point and h2c Upgrade support is not consistently implemented by upstream
servers. upstream_h2_max_streams limits concurrent streams per upstream
HTTP/2 connection and must be between 1 and 1024. A value of 1 intentionally
serializes the native upstream H2 pool, including cold connection setup, so
latency-sensitive deployments should use a larger value unless the origin
requires single-stream behavior. upstream_h2_ping_interval_secs enables
upstream HTTP/2 keepalive pings. Both h2 settings require
upstream_http_version to allow HTTP/2.
During the 1.6 native preview, the native HTTP/1 proxy path supports
h2c/prior-knowledge upstreams and TLS ALPN-negotiated upstream HTTP/2 for
upstream_http_version = "http2", including upstream_h2_max_streams and
upstream_h2_ping_interval_secs. For TLS upstreams,
upstream_http_version = "http1-and-http2" advertises both h2 and
http/1.1, then sends the upstream request with the protocol selected by ALPN.
Plaintext http1-and-http2 remains HTTP/1.1-only unless
upstream_h2c_upgrade = true is explicitly configured.
For downstream HTTP/2, :authority is the sole vhost-routing authority;
Fluxheim discards any ordinary Host fields before adapting the request and
rejects requests without authority. Native HTTP/1 origins may send a bounded
sequence of informational responses before the final response. Generic status
101 is accepted only by the explicit WebSocket or h2c takeover paths.
Downstream HTTP/1 similarly derives one effective authority. HTTP(S)
absolute-form and CONNECT authority-form targets must agree with Host when it
is present; malformed authorities, unsupported schemes, fragments,
backslashes, invalid percent escapes, and conflicts receive 400 before
routing. HTTP/1.0 requests containing Transfer-Encoding are always rejected
and closed even if they request keep-alive. Chunked bodies have independent
bounds for decoded and encoded bytes, chunk size/count, size-line length, and
aggregate extension bytes; extension syntax is validated and fragmented input
is decoded incrementally.
Fluxheim supports only a single chunked HTTP/1 response transfer coding and
rejects coding chains it cannot decode completely. It removes fixed
hop-by-hop response fields plus every field nominated by Connection before
downstream delivery or cache admission.
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 the native HTTP/1.1 upstream 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 on the compatibility runtime. During the
1.6 native-runtime cutover preview it remains an explicit native HTTP/1
transport-policy blocker until Fluxheim has a safe native socket path with
parity tests.
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 Fluxheim's bounded 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, plus static-pool
nginx-compatible Ketama modes nginx-consistent-source-hash /
nginx-consistent-hash / ketama, nginx-consistent-uri-hash,
nginx-consistent-header-hash, and nginx-consistent-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. The
nginx-compatible Ketama modes build a static CRC32 continuum with 160 points per
weight unit and are intended for migration cases that need nginx/Pingora
Ketama-style request-to-backend mapping; they are also static proxy.upstreams
only and reject file, HTTP, and DNS discovery pools in this release. Ketama
uses unsalted CRC32 by design so its mapping remains compatible with nginx and
Pingora Ketama behavior. Do not use Ketama when the hash key is attacker
controlled and deterministic backend targeting is unacceptable; use Fluxheim's
salted rendezvous, bounded-load consistent, or Maglev selectors instead.
max_iterations bounds how many ready candidates 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 Fluxheim'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, nginx-compatible Ketama, 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
Fluxheim'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, 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. It must
not contain CR/LF or userinfo (@). port_override sends checks to a
different port on the same backend address.
Omit port_override to check the backend's normal port. reuse_connection is
accepted for configuration compatibility with older releases, but the native
HTTP/gRPC health-check client introduced in 1.6.17 opens a fresh bounded
connection per probe. 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 Fluxheim's native
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. min_healthy_backends
defaults to 1; when passive ejection would leave fewer than this number of
selectable backends, Fluxheim ignores passive ejection for that selection pass
instead of failing the entire pool. Set it to 0 only when strict fail-closed
behavior is preferred over availability during a full-pool passive ejection.
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 after the passive-health floor and other policy gates,
Fluxheim returns a proxy error instead of falling back to a configured primary
upstream. Disabled, drained, saturated, or active-health-unready backends are
not revived by the passive-health floor.
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 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 load-balancer implementation does not 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. Optional and required
second-based proxy/PHP/load-balancer health-check timeout fields reject 0 and
values above 86400 seconds so a malformed config cannot pin connections or
workers indefinitely.
websocket = true enables HTTP/1.1 WebSocket upgrade forwarding 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_read_timeout_secs,
downstream_write_timeout_secs,
downstream_total_response_timeout_secs, and
downstream_min_send_rate_bytes_per_sec protect the client-facing side of
proxied requests and responses. The read timeout caps each stalled downstream
request-body read and defaults to 60 seconds; this applies to HTTP/1 and to
HTTP/2 DATA-frame waits in Fluxheim's native HTTP/2 stack. The write timeout
caps each stalled downstream write and defaults to 30 seconds. The total
response timeout is an absolute HTTP/2 response-write lifetime bound and
defaults to 300 seconds; it is not reset by partial writes or client
WINDOW_UPDATE frames. The minimum send rate asks Fluxheim 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.
HTTP/2 DATA frame size is kept at 16 KiB, the advertised receive window is
fixed at 64 KiB, per-stream send buffering is capped at 256 KiB, and
pending-accept reset streams are capped at 8 so slow-reading HTTP/2 clients
cannot use the h2 crate's larger defaults to accumulate unbounded response
buffers.
Fluxheim also enforces server.limits.max_request_headers after HTTP/2 header
decoding; duplicate header values such as split Cookie crumbs count toward
that limit. 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, Upgrade: websocket, one Sec-WebSocket-Key,
and Sec-WebSocket-Version: 13, Fluxheim forwards the request upstream with a
canonical Connection: upgrade and Upgrade: websocket. Upgrade requests
bypass proxy cache policy and should normally use route-level read/send
timeouts sized for long-lived connections. The current native WebSocket tunnel
uses the selected upstream read_timeout_secs as an absolute tunnel lifetime
cap, not as an idle timeout; active tunnels are closed when that duration
expires. Leave websocket = false on normal
HTTP routes; Fluxheim strips HTTP/1 Connection and Upgrade request headers
in that mode so normal proxy routes cannot tunnel upgraded protocols
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 forwarded to the auth endpoint. For common request-context headers,
Fluxheim does not copy client-supplied values: X-Original-URI,
X-Forwarded-URI, X-Auth-Request-Redirect, X-Forwarded-For, X-Real-IP,
X-Forwarded-Host, and X-Forwarded-Proto are synthesized from the trusted
request context when those names are allow-listed. Repeated Cookie fields are
joined with ; before the auth subrequest, matching the origin request path.
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.
max_in_flight bounds active synchronous authorization calls for each
configured auth service. Fluxheim acquires this permit before submitting work
to Tokio's blocking pool; saturation fails closed with 503 instead of
growing the blocking queue. A second process-wide semaphore caps aggregate
authorization work across every vhost and route at 256; per-service permits
are acquired first so one saturated service cannot reserve global capacity.
Authorization then shares Fluxheim's hierarchical request-driven blocking-work
budget with Wasm, traffic mirrors, disk-cache work, and ACME challenge reads.
The total is 256, non-critical work is capped at 224, and class ceilings are
auth 96, Wasm 96, disk cache 32, mirror 8, and critical ACME work 32.
This prevents one request class from consuming every slot and reserves critical
capacity. The native Tokio runtime explicitly permits 384 blocking threads,
leaving another 128 slots outside request-driven admission for lifecycle,
admin, and recovery work. Valid per-service values are 1..=256 and the default is 64. 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. Cache-Control: private and no-store also suppress compression, including qualified forms
such as private="Set-Cookie". 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. Accept-Encoding q-values are
parsed as finite RFC-style values from 0 through 1 with at most three
decimal digits. Explicit coding entries take precedence over *; malformed,
unknown, duplicate, or empty parameter/list forms fail closed to an identity
response rather than enabling an encoding.
min_bytes and max_input_bytes bound the original response size.
max_output_bytes bounds the encoded response size with a codec sink that
rejects a write before its logical output length exceeds the limit. It is not
an exact RSS ceiling: the allocator may reserve additional Vec capacity and
codec-internal working memory is separate. Emitted output allocations are
transferred without a second body copy, and an encoder is discarded after any
output-limit or allocation failure. 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. Existing path prefixes are checked even
when a later descendant is missing. Request paths are symlink-free, including
intermediate directories. Static serving also rejects traversal, dotfiles by
default (including non-UTF-8 OS filenames beginning with .), 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 when any existing parent is not owned by
root or the service user, or 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.
The storage-bin backend holds a root-local advisory lock before creating or
validating persistent layout metadata. Cross-node exclusion therefore depends
on correct flock behavior from the selected filesystem and CSI driver. Use a
separate local, ReadWriteOnce, or ReadWriteOncePod cache volume per replica.
Do not share one RWX storage-bin root between replicas unless cross-node lock
behavior has been explicitly tested; high-assurance deployments should enforce
single-writer ownership through orchestration as well. Peer fill is the
supported mechanism for sharing cache warmth between independent roots.
[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 as authenticated data. The combined cache key
remains inside the encrypted payload. Encrypted filesystem filenames and
storage-bin indexes use HMAC-SHA-256 identities made with a separately derived
index key, so persisted metadata does not provide an offline oracle for
candidate URLs or query values. Decrypted objects are validated against the
requested cache identity before use. Local cache encryption is intended for
cache-at-rest protection; it does not encrypt memory cache contents.
Fluxheim creates a persistent random identity for each encrypted cache root and
uses HKDF-SHA-256 to derive separate root-specific AES and index keys from the
configured local master key. Random AES-GCM nonces are accounted by a locked,
durable counter for that effective root key. Encryption fails closed at 2^32
object-encryption invocations and logs a security warning before that bound.
Preserve .fluxheim-encryption-*, .fluxheim-gcm-*, and
.fluxheim-index-key-* state with the cache root. Missing, truncated, or
inconsistent active-key or counter state fails startup instead of resetting a
counter. A missing root identity causes a new root key and mandatory cold purge.
Do not clone or restore one initialized encrypted root into multiple
concurrently active replicas; use independent roots and peer fill.
The first 1.7.12 startup on an existing encrypted root creates root-bound
state and cold-purges legacy encrypted objects and indexes. Envelope v1 is no
longer accepted. Changing a local master key also triggers a cold purge before
the new derived key is used. Cache contents are expendable, but the hidden
encryption state is cryptographic state: filesystem rollback of that complete
state cannot be detected locally. High-assurance deployments requiring
externally enforced rollback resistance should use OpenBao/KMS-managed key
lifecycle and must not restore cache cryptographic state from an old snapshot.
Filesystem disk-cache fills with the local provider encrypt streamed objects in
bounded AEAD chunks, so enabling local encryption does not require Fluxheim to
copy a complete streamed origin response back into heap before committing it to
disk.
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 is associated data; the combined cache identity is encrypted
inside the object and checked after decryption, so substituted objects are
discarded without exposing cache URLs in envelope metadata. The default
local-key provider does not require OpenBao. Fluxheim creates a random HMAC
index key per OpenBao cache root and stores only its Transit-encrypted form, so
the OpenBao token itself is never used as index-key material. Transit calls do
not follow HTTP redirects,
and Transit JSON responses are read with an explicit size limit before parsing.
OpenBao Transit accepts a single plaintext value per encrypt request, so
Fluxheim bounds concurrent OpenBao encrypted commit heap usage and may refuse a
cache fill instead of buffering too many large plaintext objects at once.
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 cacheability predictors. The
native HTTP/1 memory-cache path uses Fluxheim-owned bounded cache-pass counters;
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.origin_protection], [vhosts.cache.origin_protection], and
route-scoped origin_protection configure a cache-aware origin-fill budget for
Fluxheim-owned cache fill paths. It is disabled by default and requires the
owning cache policy to be enabled. The first protected path is range slice
fill: when a requested slice is missing locally and Fluxheim would fetch it
from origin, the request must acquire a per-vhost or per-route fill permit.
[cache.origin_protection]
enabled = true
max_concurrent_fills = 32max_concurrent_fills is bounded to 1-1024. When the budget is saturated,
Fluxheim refuses the protected slice fill with 503 instead of falling through
to the normal origin path. This is intentionally fail-closed for origin
protection: operators should enable it only on cache policies where protecting
origin capacity is preferable to serving an uncached slice during brownout.
Existing cache locks still coalesce same-key fills; origin protection is a
route/vhost-level budget across distinct cache keys. Metrics builds expose
fluxheim_cache_origin_protection_enabled_policies and
fluxheim_cache_origin_protection_max_concurrent_fills, and policy activity
records origin_protected when the budget rejects a fill.
[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
shared_secret_file = "/run/secrets/fluxheim-peer-fill"
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 tests
or deployments where another layer already authenticates and encrypts the peer
hop. Non-loopback http:// peer URLs also require shared_secret_file;
Fluxheim rejects that configuration without an application-level peer-fill
shared secret. Security warning: unauthenticated cross-host plaintext peer fill
has no response integrity. Any network-path attacker between Fluxheim nodes can
inject a cacheable 200 response and poison the local cache for that URL until
the accepted freshness expires.
Production cross-host peer fill should use https:// peer URLs with valid
certificate verification, or a local TLS/mTLS sidecar or encrypted overlay that
provides the same integrity guarantee. shared_secret_file enables Fluxheim's
application-level peer-fill authenticity check: outbound peer-fill requests are
signed with a nonce, peers sign the status, canonical response headers, and body
digest, and unsigned or tampered peer responses are discarded before cache
storage. Configure the same high-entropy secret file on every node in the peer
fill cluster. This HMAC layer is required for non-loopback plaintext peer-fill
URLs and is defense in depth for other deployments; HTTPS with verified peer
identity remains the preferred production 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-fill requests forward the original request Host header so the
peer can perform the same host-scoped cache-key lookup. They otherwise use the
authority from their configured base_url plus safe negotiation headers such
as Accept, Accept-Encoding, and Accept-Language. Credentials such as
Authorization and Cookie are not forwarded. Outbound peer-fill requests
carry X-Fluxheim-Peer-Fill: 1; Fluxheim strips that internal marker from
client-supplied requests before normal proxy handling. Requests with
Cache-Control: only-if-cached are answered only from local cache and are not
allowed to contact origin, which prevents recursive peer-fill loops in cyclic
peer topologies.
examples/cache-peer-fill.toml shows the focused validated fixture. Metrics
builds expose aggregate origin-protection and peer-fill configuration through
fluxheim_cache_origin_protection_enabled_policies,
fluxheim_cache_origin_protection_max_concurrent_fills,
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-origin-protection-enabled,
--expect-origin-protection-max-concurrent-fills,
--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-origin-protection-enabled,
--expect-origin-protection-max-concurrent-fills,
--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 changes the upstream response but should not become part of the shared
cache identity. Requests containing Authorization or Proxy-Authorization
always bypass shared-cache lookup and storage, independently of this list. The
configured 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
request query string contains any listed parameter name. Matching is exact on
the raw key before = and on its percent-decoded form, so preview=true and
pr%65view=true both match 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 query
parameter has the exact configured value. Matching checks raw and
percent-decoded parameter names and values, so mode=private and
mode=priv%61te both match { mode = "private" }.
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. Fluxheim accepts at most
16 configured vary request headers, matching the runtime Vary field cap.
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: max-age=<ttl> before cache admission. Origin
private, no-store, no-cache, Set-Cookie, and other shared-cache
rejections are preserved and still prevent storage unless
ignore_origin_cache_headers is explicitly enabled for a trusted static route.
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. Malformed response Cache-Control, duplicate security or
freshness directives, and invalid quoted values fail admission rather than
falling back to these TTLs. For valid policy, s-maxage takes precedence over
max-age. 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
Fluxheim marks stale-if-error eligible is allowed. Stored responses carrying
must-revalidate, proxy-revalidate, or s-maxage are never reused stale;
that origin restriction is persisted across memory and disk cache tiers and
overrides configured stale windows. 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"
# crl_path = "tls/client.crl.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.
The allow-list is deterministic across protocol families: when an explicit or
profile-derived list has no TLS 1.2 suites, TLS 1.2 is disabled; when it has no
TLS 1.3 suites, TLS 1.3 is disabled. OpenSSL does not retain provider defaults
for an omitted protocol family and starts from the current Mozilla v5 acceptor
baseline rather than the legacy v4 template that disabled TLS 1.3. Release
tests perform real handshakes against both one-family policy shapes.
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.
Each loaded certificate-chain file is capped at 1 MiB and 16 certificates, and
each private-key file is capped at 64 KiB. Fluxheim reads these inputs through
one retained file descriptor and rejects growth beyond the admitted size.
Private-key PEM buffers use sanitization::SecretVec; the rustls path also
protects the transient decoded DER allocation until the selected crypto
provider has constructed its signing key. Partial file reads and growth-race
rejections remain inside clear-on-drop storage. Rustls PEM payloads use staged
constant-time-oriented Base64 decoding, and parse errors expose only a redacted
error class rather than an input byte or position.
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"
crl_path = "/etc/fluxheim/tls/client-crls.pem" # optional PEM CRL bundlemode = "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 and optional CRL paths use 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.
Client-auth CA bundles are capped at 8 MiB and 4096 certificates. Oversized or
over-count bundles fail listener construction instead of consuming unbounded
memory during startup or certificate reload.
When crl_path is set, Fluxheim accepts a bounded PEM bundle containing 1 to
64 CRLs and checks the complete presented client chain. Hierarchical client PKI
therefore needs the issuing intermediate CA CRL for each client certificate and
the parent CA CRL for each non-root intermediate. Rustls rejects unknown
revocation status and expired CRLs; OpenSSL enables CRL_CHECK and
CRL_CHECK_ALL. Malformed, empty, over-count, oversized, expired, or revoked
inputs fail closed. CRL policy changes require a process upgrade, so a failed
replacement cannot mutate the active listener configuration. Without
crl_path, ordinary client-auth deployments validate certificate trust but do
not perform revocation checks. Required client authentication under
tls.fips.required = true or tls.iso19790.required = true must configure a
CRL bundle.
The OpenSSL SNI store parses and admits client-auth policy once per atomic
generation and shares reference-counted CA certificates across certificate
contexts. It rejects a configuration when the CA/CRL input size projected
across the active and replacement SNI context generations exceeds 128 MiB.
OpenSSL's internal server session-cache capacity is divided across all SNI
contexts from a global 4096-entry budget, preventing the backend default from
being multiplied by every configured certificate. Complete reload operations
are serialized, and every SNI-selected SSL connection retains an explicit lease
for its certificate generation and an independent drain-waker registration.
At most two generations may be live. When an older generation blocks rotation,
Fluxheim marks it for draining; the native OpenSSL HTTP/1, HTTP/2, and
connection-takeover I/O paths wake and close every retained connection before
the reload automatically retries for up to 10 seconds. If connections cannot
release within that bound, the reload fails closed without replacing the active
certificates. This enforced lifetime cap makes the active-plus-replacement
memory projection match actual retained generations. OpenSSL SSL ex-data uses
one process-global connection-lease slot, including when listeners or
certificate stores are reconstructed in-process. Fluxheim verifies every lease
attachment by reading that slot back and terminates if OpenSSL cannot preserve
the connection-generation lifetime invariant under extreme allocation failure.
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 any existing ancestor has an untrusted owner or 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 and
OpenSSL builds, 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.
Every issuer used to create a new account must set
terms_of_service_agreed = true together with the exact HTTPS
terms_of_service_url reviewed by the operator. The bounded online directory
probe requires HTTPS newNonce, newAccount, and newOrder endpoints and an
exact match with the directory's advertised meta.termsOfService. Private
directories that omit this metadata may set
allow_unadvertised_terms_of_service = true; the default is false, a
directory-advertised mismatch is always rejected, and the override itself
requires explicit acceptance plus an HTTPS terms URL. Existing account
credentials can still be loaded without silently accepting changed terms.
Optional ca_bundle_file replaces platform roots for that issuer with a bounded,
no-follow PEM trust bundle; use it for private ACME CAs or deliberate CA
pinning. Directory endpoint validation parses each URL as an HTTP URI and
requires an HTTPS scheme, a bounded non-userinfo authority, and an absolute
path; a superficial https:// prefix is not sufficient.
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"
terms_of_service_agreed = true
terms_of_service_url = "https://replace-with-the-current-issuer-terms.example/"
# Only for a private directory that omits meta.termsOfService:
# allow_unadvertised_terms_of_service = true
[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"
terms_of_service_agreed = true
terms_of_service_url = "https://replace-with-the-current-issuer-terms.example/"
[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. CIRCL's country-only database supplies the
standard country.iso_code field. Its combined Country and ASN database stores
the ASN as the string field country.AutonomousSystemNumber; the explicit
circl-geo-open provider enables the bounded decoder for that schema. Databases
are ordered local fallbacks when fallback_enabled = true; Fluxheim fills
missing country or ASN fields from later databases when possible. The pinned
real-database setup and opt-in static/proxy/load-balancer proof are documented
in docs/geoip.md.
Fluxheim does not download GeoIP databases in-process. Each MMDB file is capped
at 512 MiB, each loaded GeoIP runtime is capped at 1 GiB total, and at most
eight databases are accepted. Aggregate capacity is checked from the verified
open descriptor before its contents are allocated, read, or parsed. The loader
allocates exactly the admitted length, reads it completely, then probes for one
extra byte separately so concurrent file growth cannot amplify the heap
allocation. Paths must be absolute and symlink-free; the file and every parent
directory must be owned by root, the effective service user, or the platform
root-equivalent owner and must not be group- or world-writable. Fluxheim rejects
shortened, grown, descriptor-identity-changed, or metadata-changed databases
during loading. GeoIP update jobs should prepare and verify a replacement under
a trusted directory, apply safe ownership and modes, 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. The Geo-Context API also validates these invariants
and canonicalizes valid country values before they can reach policy. 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. The
native HTTP/1 runtime shards the local rate-limit table and uses bounded
incremental prune scans when a shard is full, so cleanup work does not sweep an
entire table in one request. Very large table_max_entries values can still
increase retained memory and the number of expired entries that need later
cleanup under many-identity floods. 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.
In delay mode, delayed native requests are intentionally counted against
vhost/route concurrency limits while they sleep. This prevents unbounded delayed
tasks from parking outside the configured concurrency budget; use a conservative
max_delay_ms and max_in_flight together for public routes.
[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_in_flight = 8
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"
APP_MEMORY_LIMIT = "256M"
[vhosts.routes.php.fpm]
# Default mode. Fluxheim connects to an operator-managed php-fpm pool.
mode = "external"
tcp = "php-fpm:9000"
# Required when TCP endpoints use loopback, private, or link-local IP literals.
# allow_private_tcp_upstreams = true
# 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.
# Fluxheim keeps generated names compact and validates the complete Unix socket
# address before spawn. Keep socket_dir short enough for the platform's Unix
# socket path limit; an oversized final address fails startup instead of being
# truncated by php-fpm.
[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}. {query} is
allowed only after the URL authority, for example in the path or query string,
because placing untrusted query text inside the authority can create open
redirects. 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.
Managed PHP-FPM validates php_fpm_binary at config load and again immediately
before each supervised spawn. The binary path must be absolute, must not contain
parent traversal, must not be or be below a symlink, must point directly to an
executable regular file owned by root or the Fluxheim service user, and must
not be group/world writable or below an untrusted or group/world-writable
ancestor. Fluxheim opens and validates the executable once, retains that
descriptor through spawn, supplies a fixed minimal PATH, and runs the managed
master and workers in a dedicated process group for group-wide shutdown.
Adding or removing a managed vhost/route pool, or changing its process and
supervision settings, is classified as a process-upgrade change. Request-time
PHP routing, limits, FastCGI transport, and response policy remain snapshot-safe.
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. max_in_flight caps concurrent
PHP-FPM requests for that vhost or route before request body buffering and
FastCGI dispatch; it defaults to 8 to bound the number of fully buffered PHP
responses held at once. 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. On Unix, the securely
created spool entry is unlinked immediately and Fluxheim retains its open file
descriptor for request and retry readers, preventing a crash from leaving a
named plaintext upload behind. Overlapping readers use independent logical
offsets with bounded positional disk reads, so one reader cannot reset or
consume another reader's body position. Fluxheim keeps memory bodies and each
bounded positional-read buffer in sanitization::SecretVec, clears consumed
buffers immediately, and clears their full allocation capacity on cancellation,
error, or drop. This does not guarantee raw-media erasure; use encrypted storage
or tmpfs when storage remanence is in scope. 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 contribute to one stricter absolute FastCGI request deadline; the
shortest of php.request_timeout_secs, php.fpm.read_timeout_secs, and
php.fpm.write_timeout_secs covers request transmission and complete response
collection. A timed-out keepalive connection is discarded because its FastCGI
protocol state is no longer reusable.
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. When the PHP response
already carries restrictive origin cache policy such as Cache-Control: private, no-store, no-cache, or Pragma: no-cache, Fluxheim strips only
the internal X-Accel-Expires header and preserves the origin cache policy
instead of promoting the response to shared-cache eligibility.
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.
Unsafe TCP IP literals such as unspecified or multicast addresses are rejected.
Loopback, private, or link-local IP literals require
php.fpm.allow_private_tcp_upstreams = true; this keeps numeric local and
private-network FastCGI connectivity an explicit operator trust boundary. Use a
Unix socket for same-host php-fpm when possible. Hostname endpoints are
validated as authorities, but DNS resolution remains an operational boundary
for the php-fpm network.
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
APP_MEMORY_LIMIT; Fluxheim rejects unsafe names, control-character values, and
core CGI parameters that it owns, including SCRIPT_FILENAME,
CONTENT_LENGTH, HTTPS, PHP_VALUE, PHP_ADMIN_VALUE, 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.
[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 Fluxheim. See Feature Matrix for the complete feature/profile list.