Configuration
On this page
Single YAML file, passed as CLI argument or set via the
PRAXIS_CONFIG environment variable. See
examples/configs/ for working examples.
For individual filter configurations, see the Filter Reference.
Structure
listeners: # Required. Named listeners to bind.
filter_chains: # Named, reusable filter chains.
clusters: # Optional. Standalone cluster defs (health checks).
admin: # Optional. Admin health endpoint.
body_limits: # Optional. Global body size ceilings.
metrics: # Optional. Prometheus metric collection toggles.
runtime: # Optional. Thread pool and logging tuning.
shutdown_timeout_secs: # Optional. Graceful drain time (default: 30).
insecure_options: # Optional. Dev/test overrides. See developing/getting-started.md.
Validating Configuration
Use --validate (or -t) to check configuration
without starting the server. The flag loads the config
through the same parsing and validation path used
during startup, including filter pipeline construction
and ordering checks.
praxis --validate --config praxis.yaml
praxis -t -c praxis.yaml
Exits 0 on success. Exits non-zero and prints an
error to stderr on failure. Validation warnings (active
insecure_options, degraded upstream TLS, likely filter
config typos) are printed to stderr either way, in the
PRAXIS_LOG_FORMAT format. Does not bind listener ports
or enter the server runtime.
Dumping Effective Configuration
Use --dump (or -T) to validate and dump the
effective parsed configuration as YAML to stdout. The
output includes the effective parsed config (with
defaults applied) plus resolved top-level listener
chains.
praxis --dump --config praxis.yaml
praxis -T -c praxis.yaml
Exits 0 on valid config, writing YAML to stdout.
Exits non-zero and writes errors to stderr on failure.
Validation warnings go to stderr, as with --validate.
Does not start the proxy or bind listeners. --dump
and --validate are mutually exclusive.
Dynamic Configuration Reload
Praxis watches the config file for changes and automatically reloads filter pipelines without restart or disruption. When the file is modified, the server validates the new config, rebuilds pipelines, and swaps them atomically. In-flight requests complete on the old pipeline; new requests pick up the new config.
If the new config is invalid (bad YAML, unknown filter, validation failure), the server logs the error and continues serving with the old config.
The watcher compares the file against the exact text the running config was parsed from, so an edit that lands while the server is still starting (before the watch exists) is applied by the watcher’s first pass rather than silently adopted as the baseline.
Dynamically reloadable:
- Filter pipeline configuration
- Metrics collection settings (
metrics.filter_duration) - Router routes and path mappings
- Load balancer endpoints and weights
- Rate limit and circuit breaker settings
- Health check configuration
- Log level overrides (
runtime.log_overrides)
Requires restart (logged as warning):
runtime.loggingdestination or buffering- Listener add, remove, or address rebind
- Listener
max_connections,downstream_read_timeout_ms, anddownstream_keepalive_timeout_ms - Compression module addition
- TLS enable/disable, and any change inside a
listener’s
tlsblock (certificate file contents are hot-reloaded by the certificate watcher; config-level TLS changes are not) - Startup-only
runtimesettings (threads,work_stealing,global_queue_interval,max_connections,max_memory_bytes,max_open_files,shed_on_fd_pressure,subrequest_pool_size,subrequest_max_connections,subrequest_circuit_breaker,upstream_ca_file,upstream_keepalive_pool_size) - The
adminsection (the admin endpoint binds at startup)
Rejected (reload fails, nothing changes):
- Protocol change (HTTP to TCP or back) on an existing listener. Its handler executes only filters of the protocol it was started with, so the reload is refused until the change is reverted or the process restarts.
Stateful filters (rate limiter, circuit breaker) reset their state on reload. Operators should expect a brief burst window for rate limiters and a closed circuit for circuit breakers immediately after reload.
Three kinds of state survive a reload: key-value store
entries; sticky-session bindings for a cluster whose
max_entries, ttl and eviction policy did not change
(a change to any of them starts that cluster’s sessions
afresh); and endpoint health (which endpoints are marked
unhealthy) for a cluster whose health_check did not
change. Only the unhealthy flag is carried, not the
failure count behind it, so the new probes must confirm
recovery before such an endpoint rejoins rotation. Clear
or rewrite key-value entries through the admin API when
a reload changes what they mean.
See hot-reload.yaml for an example.
Admin
admin.address binds a separate HTTP listener that serves
/healthy, /ready, /metrics, /api/kv/*, and
/api/pipelines.
/healthyreturns200 OKwith{"status":"ok"}once the server is accepting connections (liveness)./readyreturns per-cluster health status with healthy/unhealthy/total counts when active health checks are configured; it returns503 SERVICE UNAVAILABLEwhen any cluster has zero healthy endpoints. Without health checks,/readyreturns{"status":"ok"}./metricsreturns Prometheus text exposition format with HTTP request metrics (praxis_http_requests_total,praxis_http_request_duration_seconds). Whenmetrics.filter_durationis enabled, also exposes per-filter hook duration histograms (praxis_filter_duration_seconds, labels:filter,phase,stream)./api/kv/{store}/{key}reads and writes entries in configured key-value stores (when any are configured)./api/pipelines(GET) returns a JSON snapshot of every listener’s resolved filter pipeline, including branch chains and rejoin targets — the runtime introspection view of the pipeline configuration. Filter it with?listener=NAME.
Any other path returns 404 NOT FOUND. Useful for orchestrator
health checks and monitoring without exposing them on
the main listeners. The admin listener has no
authentication; keep it on loopback or a management
network (non-loopback binds require
insecure_options.allow_public_admin).
admin:
address: "127.0.0.1:9901"
When admin.verbose: true, the /ready response
includes per-cluster detail (cluster names, health
counts). Default is false to avoid leaking internal
topology.
admin:
address: "127.0.0.1:9901"
verbose: true
By default, the admin endpoint must bind to a loopback
address (127.0.0.1 or [::1]). Binding to any
non-loopback address (including 0.0.0.0 / [::] or a
LAN IP) is a validation error unless
insecure_options.allow_public_admin: true is set.
Annotated Example
listeners:
- name: web
address: "0.0.0.0:8080"
filter_chains:
- observability
- routing
filter_chains:
- name: observability
filters:
- filter: request_id
- filter: access_log
- name: routing
filters:
- filter: router
routes:
- path_prefix: "/api/"
cluster: api
- path_prefix: "/"
cluster: web
- filter: load_balancer
clusters:
- name: api
endpoints: ["127.0.0.1:4000"]
- name: web
endpoints: # multi-line form
- "127.0.0.1:3000" # (equivalent to inline
- "127.0.0.1:3001" # array above)
Listeners
Each listener has a required name, an address, optional
tls, optional protocol (defaults to http), and a list
of filter_chains to apply. HTTP listeners require at least
one filter chain; filter_chains may only be omitted on
protocol: tcp listeners that route via upstream or
cluster instead.
listeners:
- name: public
address: "0.0.0.0:80"
filter_chains: [main]
- name: secure
address: "0.0.0.0:443"
filter_chains: [main]
tls:
certificates:
- cert_path: /etc/praxis/tls/cert.pem
key_path: /etc/praxis/tls/key.pem
The name field uniquely identifies the listener and is
used to resolve its pipeline at startup.
Network Binding
Binding to 0.0.0.0 or [::] exposes the listener
on all network interfaces. For local development,
prefer 127.0.0.1. In production, bind to specific
internal IPs and use firewall rules to restrict
access. The default configuration binds to
127.0.0.1:8080 as a security precaution.
TCP Listeners
TCP listeners set protocol: tcp and require either a
static upstream address or a cluster name for load-
balanced routing. Filter chains are optional. The two
fields are mutually exclusive.
listeners:
- name: postgres
address: "0.0.0.0:5432"
protocol: tcp
upstream: "10.0.0.1:5432"
Optional tcp_session_timeout_ms sets a hard deadline
for TCP connections. Active connections are terminated
after this duration regardless of activity:
listeners:
- name: postgres
address: "0.0.0.0:5432"
protocol: tcp
upstream: "10.0.0.1:5432"
tcp_session_timeout_ms: 300000 # 5 minutes
Optional tcp_max_duration_secs caps the total session
duration regardless of activity:
listeners:
- name: postgres
address: "0.0.0.0:5432"
protocol: tcp
upstream: "10.0.0.1:5432"
tcp_max_duration_secs: 3600 # 1 hour
Downstream Read Timeout
Optional downstream_read_timeout_ms sets how long the
proxy waits for data from downstream clients during body
reads. Mitigates slow-body attacks on HTTP listeners.
listeners:
- name: web
address: "0.0.0.0:8080"
downstream_read_timeout_ms: 10000 # 10 seconds
filter_chains: [main]
Pingora applies its own 60s default for initial request header reads on fresh connections. This setting controls body read timeouts within an active request.
Downstream Keep-Alive Timeout
Optional downstream_keepalive_timeout_ms closes an idle
HTTP/1.x keep-alive client connection once it has waited
that long for its next request. Without it, idle
keep-alive connections stay open until the client closes
them, and each one holds a file descriptor, so a fleet of
idle or half-dead clients can exhaust the process limit.
listeners:
- name: web
address: "0.0.0.0:8080"
downstream_keepalive_timeout_ms: 60000 # 60 seconds
filter_chains: [main]
The timeout is applied in whole seconds, rounded up
(1 to 3600000 ms), and replaces any Keep-Alive: timeout hint a client sends. It never turns keep-alive
on: Connection: close and HTTP/1.0 clients without
Connection: keep-alive are still closed after their
response. HTTP/2 connections are not affected. Like the
other listener settings, a change needs a restart.
Max Connections
Optional max_connections caps concurrent connections
per listener. HTTP listeners reject excess requests
with 503 Service Unavailable and a Retry-After: 1
header. TCP listeners close the socket immediately.
listeners:
- name: public
address: "0.0.0.0:8080"
max_connections: 10000
filter_chains: [main]
A slot is held for the request lifetime (HTTP) or connection lifetime (TCP) and freed on completion, error, or timeout. Each listener has an independent limit.
See max-connections.yaml for an example.
Mixed Protocols
HTTP and TCP listeners can run on a single server instance. Each listener gets its own filter chains appropriate to its protocol.
listeners:
- name: web
address: "0.0.0.0:8080"
filter_chains: [routing]
- name: db
address: "0.0.0.0:5432"
protocol: tcp
upstream: "10.0.0.1:5432"
See tls.md for TLS details.
Filter Chains
Named filter chains are defined at the top level. Each chain
has a name and an ordered list of filters. Listeners
reference chains by name via filter_chains:.
filter_chains:
- name: security
filters:
- filter: headers
response_set:
- name: "X-Content-Type-Options"
value: "nosniff"
- name: observability
filters:
- filter: request_id
- filter: access_log
- name: routing
filters:
- filter: router
routes:
- path_prefix: "/"
cluster: backend
- filter: load_balancer
clusters:
- name: backend
endpoints: ["10.0.0.1:8080"]
Chain Composition
A listener can reference multiple chains. The filters from each chain are concatenated in order to form the listener’s complete pipeline. This enables reuse without duplication.
listeners:
- name: public
address: "0.0.0.0:8080"
filter_chains:
- security
- observability
- routing
- name: internal
address: "0.0.0.0:9090"
filter_chains:
- observability
- routing
The public listener runs security + observability + routing. The internal listener skips security but shares the same observability and routing chains.
Protocol Compatibility
Filters are protocol-aware. HTTP filters (e.g. router,
load_balancer) only work on HTTP listeners. TCP filters
(e.g. tcp_access_log) work on both HTTP and TCP listeners.
An HTTP listener’s protocol stack includes TCP, so it
supports TCP-level filters too.
Payload Size Limits
Global hard ceilings on request and response payload
size. These apply across all body modes (Stream,
StreamBuffer). When a filter also declares a per-filter
max_bytes, the smaller of the two limits is enforced.
Requests exceeding the limit receive 413 (Payload Too
Large).
body_limits:
max_request_bytes: 10485760 # 10 MiB
max_response_bytes: 5242880 # 5 MiB
Both default to 10 MiB (10,485,760 bytes) when
omitted. Setting either to null removes the ceiling
but requires insecure_options.allow_unbounded_body: true; without that flag, startup fails with a
validation error.
Metrics
Optional Prometheus metric collection toggles. HTTP
request metrics on /metrics are always recorded when
the admin endpoint is enabled. Per-filter hook duration
histograms are opt-in.
metrics:
filter_duration: true
filter_duration defaults to false. When enabled,
records wall-clock duration for each HTTP filter hook
(request/response headers and body). Histogram labels:
filter (filter name), phase (request or
response), stream (headers or body).
Requires admin.address to scrape via /metrics.
Enabling filter_duration without admin logs a startup
warning; metrics are recorded but not exposed.
Header and Request Limits
Praxis inherits header and request limits from Pingora’s HTTP/1.x parser. These are compile-time constants in Pingora and are not currently configurable in Praxis.
| Limit | Value | Notes |
|---|---|---|
| Max total header size | 1,048,575 B (~1 MiB) | Includes request line |
| Max number of headers | 256 | HTTP/1.x only |
| Request-URI max size | shared with header limit | No separate cap |
| Header read timeout | 60 s | Pingora default |
| Body buffer chunk | 65,536 B (64 KiB) | Per-read buffer |
HTTP/2 header limits are governed by the h2 crate’s
HPACK and frame-level settings (typically 16 KiB for
HEADERS frames by default, negotiated via SETTINGS).
Requests that exceed header size or count limits receive a 400 Bad Request from Pingora before reaching the filter pipeline.
Runtime
Worker thread pool and scheduling configuration.
runtime:
threads: 8 # 0 = auto-detect (default)
work_stealing: true # default: true
threads: number of worker threads per service. When set to 0 (the default), the thread count is auto-detected from available CPUs.work_stealing: allow work-stealing between worker threads of the same service. Enabled by default.global_queue_interval: intended global queue check interval for the tokio scheduler. Currently a no-op: the async runtime is managed by Pingora, which exposes no way to apply this value, so it is ignored.Option<u32>, defaults tonull(unset); setting it logs a startup warning that it has no effect.upstream_keepalive_pool_size: maximum number of idle upstream connections kept per thread.Option<usize>, defaults toSome(64). Set tonullto use Pingora’s default of 128 per thread.max_connections: process-wide maximum concurrent connections across all listeners. When set, new connections beyond this limit are rejected.Option<u32>, defaults toNone(disabled). Distinct from per-listenermax_connections.max_memory_bytes: process-wide RSS memory limit for load shedding. When set, the proxy monitors resident memory and rejects new requests with503 Service Unavailablewhen usage exceeds the threshold.Option<usize>, defaults toNone(disabled).max_open_files: soft limit on open file descriptors (RLIMIT_NOFILE) the process sets for itself at startup. Every connection, pooled connection, and DNS lookup holds one.Option<u64>(128 to 2^30), defaults toNone, which raises the soft limit to the hard limit; that needs no privileges and lifts the 1024 soft limit containers commonly start with. A value above the hard limit is clamped with a warning. Startup logs the limit in effect and warns when it is below 4096, or below whatmax_connectionsimplies. See Capacity Planning.shed_on_fd_pressure: reject new requests with503 Service Unavailable(and close new TCP connections) when open file descriptors near the process limit, keeping a reserve of 5% or 64 descriptors, whichever is larger, for health probes, DNS, and logs. Counted aspraxis_overload_rejects_total{reason= "file_descriptors"}.bool, defaults totrue; setfalseto let requests run into the limit instead.subrequest_circuit_breaker: per-peer circuit breaker for the shared sub-request connector used byiterative_request_router. When configured, the connector tracks consecutive transport failures per upstreamSocketAddrand rejects sub-requests while a peer’s circuit is open. Fields:consecutive_failures: failure threshold before the circuit opens (required, must be > 0).recovery_window_secs: seconds the circuit stays open before allowing a probe (required, must be > 0).half_open_timeout_secs: seconds a half-open probe may remain in-flight before the circuit resets to open (default 30).
The circuit breaker state is preserved across config reloads. Use the
transport_error: circuit_opentransition initerative_request_routerstep rules to match circuit-open rejections.
runtime:
threads: 4
work_stealing: true
upstream_keepalive_pool_size: 64
max_connections: 10000 # process-wide limit
max_memory_bytes: 1073741824 # 1 GiB
max_open_files: 65536 # default: the hard limit
subrequest_circuit_breaker:
consecutive_failures: 5
recovery_window_secs: 30
Upstream CA
upstream_ca_file sets a PEM CA file used as the root
certificate store for all upstream TLS connections.
Per-cluster tls.ca overrides this for individual
clusters.
runtime:
upstream_ca_file: /etc/praxis/tls/internal-ca.pem
This replaces the system trust store (not additive). See tls.md for details on CA trust precedence and combined bundles.
Logging
Set PRAXIS_LOG_FORMAT=json to emit structured JSON log
output instead of the default human-readable format.
Per-module log level overrides can be configured under
runtime.log_overrides:
runtime:
log_overrides:
praxis_filter::pipeline: trace
praxis_protocol: debug
This is useful for debugging a specific subsystem without flooding output from every module.
Key-Value Stores
In-memory key-value stores for runtime-updatable
mappings. A store is created in code through
KvStoreRegistry::get_or_create; the admin API reads
and updates entries in stores that already exist but
does not create them. No built-in filter or
configuration section creates a store yet, so in a
standard deployment the registry is empty and the
admin key-value endpoints answer 404 for every store
name.
Filters access stores by name through
HttpFilterContext and TcpFilterContext.
Match Types
Stores support four match types for key lookup:
| Type | Behavior |
|---|---|
exact | Key must equal the lookup key |
prefix | Stored key starts with the pattern |
suffix | Stored key ends with the pattern |
regex | Stored key matches a regex pattern |
Admin API
When admin.address is configured, CRUD endpoints
are available:
| Method | Path | Description |
|---|---|---|
GET | /api/kv/{store} | List all entries |
GET | /api/kv/{store}/{key} | Get a value |
PUT | /api/kv/{store}/{key} | Set a value (body) |
DELETE | /api/kv/{store}/{key} | Delete a key |
Writes are immediately visible to all filters on all threads. Unknown store names return 404.
Runtime Cache Semantics
Key-value stores are runtime caches, not durable storage. Data lives in memory, survives config reloads, and is lost on process exit.
The store is designed for operational overrides (routing tables, feature flags, config knobs) that can be reconstructed from an external source of truth. Do not use it as a primary data store.
Pluggable Backends
The KvBackend trait allows alternative implementations
(e.g. Redis). The default InMemoryKvBackend keeps
entries in memory. See the praxis_core::kv
module docs for the trait definition.
Graceful Shutdown
The shutdown_timeout_secs field controls how long the
server drains in-flight connections before forcing
shutdown:
shutdown_timeout_secs: 60 # default: 30
Once the drain completes, Praxis flushes queued log lines
and exports pending OTLP spans, then exits 0. A startup
failure after logging is initialized (a listener that
cannot be registered, a filter that cannot be built) is
logged the same way, flushed, and exits 1.
Default Configuration
When no configuration file is provided, Praxis starts with
a built-in default config that listens on 127.0.0.1:8080
and responds with {"status": "ok", "server": "praxis"}
on / (exact match) and 404 elsewhere. The default binds
to localhost only, preventing accidental exposure to
public networks during initial setup. This allows zero
config startup for testing. The source lives in
default.yaml. For a realistic starting point, see
basic-reverse-proxy.yaml.
Example Configs
Working examples live under examples/configs/, organized
by category:
| Directory | Contents |
|---|---|
branching | Branch chains: conditional skip, terminal, reentrance, cross-chain |
traffic-management | Router, load balancing, timeouts, redirects, rate limiting, static responses, P2C, canary, circuit breaker, health checks, gRPC detection |
payload-processing | Compression, JSON Pointer rewrite, JSON field extraction, stream buffering, size limits |
security | CORS, CSRF, IP ACL, guardrails, policy (feature-gated), forwarded headers, downstream read timeout |
observability | Access logs, request IDs, TCP access logs |
transformation | Headers, path rewrite, URL rewrite |
protocols | TCP, TLS, mixed protocol configs |
pipeline | Chain composition, conditions, failure mode, branch chains |
operations | Hot reload, admin, multi-listener, max connections, production gateway |
Branch Chains
Conditional sub-pipelines based on filter results: short-circuit responses, skip filters, retry loops, and cross-chain routing. See Branch Chains and Pipeline Concepts.
Health Checks
Per-cluster active HTTP/TCP probes and passive inline failure tracking remove unhealthy endpoints from rotation. See health-checks.yaml.
The top-level clusters: section feeds health checks (and
the /api/stats admin view and startup key-permission
checks), never proxied traffic: the data path builds its
upstreams from the clusters defined inline in the
load_balancer or tcp_load_balancer filter. A health
probe uses only a cluster’s endpoints and its
health_check block, and HTTP/TCP probes connect in
plaintext, so a top-level cluster’s data-path settings
(tls, retry_policy, the timeout fields,
load_balancer_strategy) have no effect at all. Configure
those on the inline load-balancer cluster instead.
Failure Mode
Filters declare failure_mode: open (continue on error)
or closed (reject the request). Security-critical
filters reject open mode by default. See
failure-mode.yaml.
Validation and Security
Praxis validates configuration at startup and fails closed. Ambiguous or risky settings are errors, not warnings. Insecure overrides (see getting-started.md) require explicit opt-in and emit warnings at startup.
Key validations: listener name uniqueness, filter chain reference resolution, TLS path traversal rejection, admin endpoint binding restrictions, health check SSRF protection, upstream TLS SNI requirements, and payload size enforcement.
Error Behavior
Praxis fails fast at startup for configuration problems. Common failure modes:
- Invalid YAML or missing required fields: the process exits with a descriptive error before any listener binds.
- Unknown filter chain reference: a listener references
a chain name not defined in
filter_chains:; caught at config validation. - TLS certificate load failure: the process exits if
a certificate’s
cert_pathorkey_pathcannot be read or parsed. - Address bind failure: if the listen address is already in use or invalid, the server fails to start.
At runtime:
- Unreachable upstream: the request returns 502 (Bad Gateway). Connection timeouts are configurable per cluster.
- Filter error: an
Errfrom a filter results in a 500 response to the client. The error is logged. - Payload too large: exceeding
body_limits.max_request_bytesor a filter’smax_bytesreturns 413.
Overrides
Some validations and features can be overridden for development
and testing purposes. See insecure_options in
getting-started.md.