Configuration Reference
Conduit is configured with a single file in JSON or YAML format. Both are fully equivalent — YAML is recommended because it supports comments.
conduit -c conduit.yaml # explicit path
conduit -c conduit.json # JSON is fine too
conduit # auto-discover: conduit.json → conduit.yaml → conduit.yml
conduit validate -c conduit.yaml # validate without starting
Fields accept environment variable references — "$MY_VAR" is replaced at
startup. This keeps secrets out of config files.
Optional build features
The binaries and Docker images published as “standard” already include the
standard feature bundle (jwt, consumers, forward-auth, cache, acme
— see the standard row below). Some config sections still require an
additional feature flag (or --features full):
| Feature | Flag | Config section |
|---|---|---|
jwt |
--features jwt |
jwtAuth + `` templates |
consumers |
--features consumers |
consumers |
forward-auth |
--features forward-auth |
forwardAuth |
rhai |
--features rhai |
middleware[].type: "script" |
wasm |
--features wasm |
middleware[].type: "wasm" |
tcp |
--features tcp |
type: "tcp" site |
upload |
--features upload |
upload |
redis |
--features redis |
rateLimit.store: "redis://...", cache.store: "redis://..." |
cache |
--features cache |
proxy.*.cache (implies proxy) |
disk-cache |
--features disk-cache |
cache.store: "disk:/path" |
acme |
--features acme |
tls.acme |
fault-injection |
--features fault-injection |
faultInjection |
otlp |
--features otlp |
global.otlp |
tokio-metrics |
--features tokio-metrics |
conduit_eventloop_lag_ms Prometheus gauge (no config key) |
kubernetes |
--features kubernetes |
--kubernetes-namespace CLI flag (not a config field) |
standard |
--features standard |
Bundle: jwt + consumers + forward-auth + cache + acme |
static-server |
--no-default-features --features static-server |
Bundle: static + compression + hotreload (the default set minus proxy) |
gateway |
--no-default-features --features gateway |
Bundle: proxy + jwt + consumers + forward-auth + cache + acme + compression |
full |
--features full |
Every optional feature above (static-server and gateway are shorthands, not extra capabilities) |
Download a -full binary from GitHub Releases
or build from source: cargo build --release --features full.
See docs/cli.md — Build features for binary sizes and details.
Table of Contents
Background
- Concepts — request pipeline, forwarded headers, skipPaths glob
Essentials
Routing
Reliability
Caching
Authentication
Rate Limiting & Load Shedding
Transforms
Observability
Security
Middleware
Advanced
- Connection pool
- Multi-site (virtual hosting)
- Upload
- Admin API — see also admin.md for full reference
- Prometheus metrics reference
Concepts
These sections explain how Conduit behaves — not config fields you set, but background knowledge that helps understand the rest of the reference.
Request pipeline
The order in which configured features are applied for every incoming request:
Incoming request
│
├─ 1. X-Request-ID injection — auto-generate UUID v4 if absent
├─ 2. IP filter — 403 if blocked (before any auth)
├─ 3. CORS preflight — OPTIONS short-circuit
├─ 4. Health / ACME bypass — skip all guards for /__health__ etc.
├─ 5. Inflight limit — 503 if maxInflightRequests exceeded
├─ 6. Site-level rate limit — 429 if over limit
├─ 7. Consumers — identify named client (V1/V2/V3)
├─ 8. Basic Auth — 401 if credentials missing/wrong
├─ 9. API Key — 401 if key missing/wrong
├─ 10. JWT Auth — 401 if token invalid
├─ 11. Forward Auth — delegate to external service
├─ 12. Redirects — 301/308 if path matches
├─ 13. Fault injection — abort/delay (testing only)
├─ 14. Rhai / WASM middleware — custom scripts in order
│
├─ Route matching + per-route rate limit
├─ Circuit breaker check
│
└─ Upstream request
├─ X-Forwarded-For / -Proto / -Host injection
├─ requestTransform (setHeaders / removeHeaders)
├─ Path rewrite (stripPrefix / rewrite rules)
└─ Traffic mirror (fire-and-forget)
Upstream response
├─ CRLF header protection
├─ CORS / security / custom headers injection
├─ responseTransform
├─ X-Response-Time header
├─ Retry-on-error decision
└─ Error masking (5xx body replacement)
Steps 7–11 are mutually exclusive in practice — only one auth method identifies
the client, but multiple may be configured as fallbacks. The consumers guard
runs before basicAuth and apiKey, so a consumer match takes priority.
Forwarded headers
Conduit automatically injects these headers into every proxied upstream request. No configuration is needed — they are always present.
| Header | Value | Notes |
|---|---|---|
X-Forwarded-For |
Client IP | Appended to existing value if already present |
X-Forwarded-Proto |
http or https |
Derived from whether the site has TLS configured |
X-Forwarded-Host |
Original Host header |
Lets upstreams reconstruct full URLs |
X-Request-ID |
UUID v4 | Auto-generated if absent; forwarded as-is if client sends it |
Via |
1.1 conduit |
RFC 7230 §5.7 — identifies the proxy hop; appended to existing value |
To remove any of these before forwarding, use requestTransform.removeHeaders:
# conduit.yaml
requestTransform:
removeHeaders: [X-Forwarded-For, X-Forwarded-Host]
// conduit.json
{
"requestTransform": {
"removeHeaders": ["X-Forwarded-For", "X-Forwarded-Host"]
}
}
skipPaths glob syntax
Many config sections accept a skipPaths list — requests whose path matches
are bypassed by that feature entirely. It is not a top-level field; it
appears inside basicAuth, apiKey, jwtAuth, forwardAuth, consumers,
rateLimit, and logging.
Two pattern forms are supported:
| Pattern | Matches |
|---|---|
/exact/path |
Only that exact path |
/prefix/** |
The prefix itself, /prefix/, and any sub-path |
# conduit.yaml — example inside jwtAuth
jwtAuth:
secret: "$JWT_SECRET"
skipPaths:
- /__health__ # exact match — health check bypasses JWT
- /public/** # /public, /public/, /public/assets/logo.png, …
// conduit.json
{
"jwtAuth": {
"secret": "$JWT_SECRET",
"skipPaths": ["/__health__", "/public/**"]
}
}
Note: only
/**at the end is supported as a wildcard. Patterns like/api/*/detailsor/**.jsonare treated as exact matches (no mid-path or extension wildcards).
Port and Host
# YAML
port: 8080
host: api.example.com # optional — virtual hosting
// JSON
{ "port": 8080, "host": "api.example.com" }
| Field | Type | Default | Description |
|---|---|---|---|
port |
number | 80 / 443 ¹ |
TCP port to listen on |
host |
string | — | Virtual hostname. Omit to match all Host headers (catch-all) |
¹ Default port is
443whentlsis configured,80otherwise. When no sites are configured at all, Conduit listens on8080as a fallback.
Use host when running multiple sites on the same process.
TLS / HTTPS
Manual certificates
# YAML
port: 443
tls:
cert: /etc/tls/server.crt
key: /etc/tls/server.key
httpRedirectPort: 80
// JSON
{
"port": 443,
"tls": {
"cert": "/etc/tls/server.crt",
"key": "/etc/tls/server.key",
"httpRedirectPort": 80
}
}
Auto-TLS via Let’s Encrypt
Requires
cargo build --features acme
# YAML
port: 443
tls:
acme:
email: admin@example.com
storage: ./certs
challenge: http-01
// JSON
{
"port": 443,
"tls": {
"acme": {
"email": "admin@example.com",
"storage": "./certs",
"challenge": "http-01"
}
}
}
TLS field reference
| Field | Type | Default | Description |
|---|---|---|---|
cert |
path | — | PEM certificate file |
key |
path | — | PEM private key file |
ca |
path | — | CA bundle for upstream verification |
httpRedirectPort |
number | — | Port that redirects HTTP to HTTPS |
acme.email |
string | — | Contact email for ACME account |
acme.storage |
path | — | Directory for certificate persistence |
versions/ciphersare not currently enforced (issue #189). The fields still parse, but the way Conduit builds its TLS listeners (through Pingora’sTlsSettings) gives no way to restrict protocol versions or cipher suites — TLS 1.2 and 1.3 are always both enabled with the default rustls cipher suite set, regardless of config. (Pingora 0.9 still exposes no way to set versions or ciphers on a listener:TlsSettings::build()hard-codes TLS 1.2 and 1.3.) Setting either field is a hard validation error (fails startup with an explanation) rather than a silent no-op, so a misconfigured expectation of TLS restriction can’t go unnoticed. |acme.challenge| string | — |"http-01"or"dns-01"| |acme.directory| string | — | Custom ACME directory URL. Use"https://acme-staging-v02.api.letsencrypt.org/directory"for Let’s Encrypt staging (rate-limit-free testing) | |clientAuth| object | — | mTLS client cert verification |
Note — single cert per port: Conduit does not support per-SNI certificate selection yet. When multiple HTTPS sites share the same port, the first registered cert is used for all. Use separate ports for different certificates. (Pingora 0.9’s rustls backend can pick a certificate per SNI hostname through a custom resolver; Conduit does not install one yet.)
HTTP/2
# YAML
port: 443
tls:
cert: ./certs/cert.pem
key: ./certs/key.pem
http2: {} # enable HTTP/2 with defaults
// JSON
{
"port": 443,
"tls": { "cert": "./certs/cert.pem", "key": "./certs/key.pem" },
"http2": {}
}
Full config with all fields:
http2:
maxConcurrentStreams: 100
initialWindowSize: 65535
h2c: false # HTTP/2 cleartext (internal gRPC without TLS)
// JSON
{
"http2": {
"maxConcurrentStreams": 100,
"initialWindowSize": 65535,
"h2c": false
}
}
| Field | Type | Default | Description |
|---|---|---|---|
maxConcurrentStreams |
number | 100 |
Max parallel streams per connection |
initialWindowSize |
number | 65535 |
Flow-control window in bytes |
h2c |
bool | false |
Allow HTTP/2 upgrade on plaintext connections (h2c). For TLS ports HTTP/2 is negotiated via ALPN regardless. Useful for internal gRPC without TLS. |
Compression
Add Content-Encoding: br / zstd / gzip / deflate to responses. Applies
to static files, the metrics endpoint, and fallback responses (JSON/text body
or file) — each negotiated independently against the same minBytes/types
thresholds, so a small metrics scrape or error body may stay uncompressed
even with compression enabled.
# YAML — shorthand (enable with defaults)
compression: true
# YAML — fine-grained
compression:
algorithms: [br, zstd, gzip] # Brotli first, then Zstd, then gzip
level: 6 # 1 = fastest, 9 = smallest
minBytes: 1024 # skip responses smaller than 1 KB
// JSON — shorthand
{ "compression": true }
// JSON — fine-grained
{
"compression": {
"algorithms": ["br", "zstd", "gzip"],
"level": 6,
"minBytes": 1024
}
}
The types field filters which response Content-Types are compressed:
compression:
algorithms: [br, gzip]
types:
- "text/"
- "application/json"
- "application/xml"
- "application/javascript"
- "image/svg"
| Field | Type | Default | Description |
|---|---|---|---|
algorithms |
string[] | ["br", "zstd", "gzip"] |
Compression algorithms to offer. Supported: "br" (Brotli), "zstd" (Zstandard), "gzip", "deflate" |
level |
number | 6 |
Compression level (1–9) |
minBytes |
number | 1024 |
Minimum response size to compress (bytes) |
types |
string[] | ["text/", "application/json", "application/xml", "application/javascript", "application/xhtml", "image/svg"] |
Content-Type prefixes to compress. Use ["*"] to compress all types (not recommended for binary content) |
Response Time Header
Inject X-Response-Time: <ms> into every response.
# YAML
responseTime: true
# With custom precision
responseTime:
digits: 3 # decimal places in the millisecond value
// JSON
{ "responseTime": true }
// JSON
{ "responseTime": { "digits": 3 } }
Proxy
The proxy object maps URL path prefixes to upstream targets.
Single upstream (shorthand)
# YAML
proxy:
/api: "http://backend:4000"
// JSON
{ "proxy": { "/api": "http://backend:4000" } }
Multiple targets
# YAML
proxy:
/api:
targets:
- "http://backend-1:4000"
- "http://backend-2:4000"
strategy: round-robin
stripPrefix: true
// JSON
{
"proxy": {
"/api": {
"targets": ["http://backend-1:4000", "http://backend-2:4000"],
"strategy": "round-robin",
"stripPrefix": true
}
}
}
URL rewriting
Rewrite rules are evaluated in order. The first matching rule is applied.
# YAML
proxy:
/api:
targets: ["http://backend:4000"]
stripPrefix: true
rewrite:
- from: "^/v[0-9]+/(.+)$" # strip version prefix
to: "/$1"
- from: "^/users/([0-9]+)$" # migrate legacy paths
to: "/members/$1"
// JSON
{
"proxy": {
"/api": {
"targets": ["http://backend:4000"],
"stripPrefix": true,
"rewrite": [
{ "from": "^/v[0-9]+/(.+)$", "to": "/$1" },
{ "from": "^/users/([0-9]+)$", "to": "/members/$1" }
]
}
}
}
Proxy route field reference
| Field | Type | Default | Description |
|---|---|---|---|
targets |
string[] or object[] | — | Upstream URLs (plain strings or {url, weight}) |
strategy |
string | round-robin |
Load balancing — see Load balancing |
stripPrefix |
bool | false |
Remove matched path prefix before forwarding |
hashKey |
string | — | Key for hash-based strategies: "ip", "url", "header:X-Name" |
rewrite |
object[] | — | URL rewrite rules: [{from, to}] — first match wins |
http2 |
bool | false |
Enable HTTP/2 for upstream connections |
timeout.connectMs |
number | 3000 |
TCP connect timeout |
timeout.sendMs |
number | — | Request send timeout |
timeout.readMs |
number | 30000 |
Response read timeout |
timeout.firstByteMs |
number | — | Max ms to wait for first upstream response byte (overrides readMs) |
timeout.perTryMs |
number | — | Per-retry timeout |
websocket |
bool | false |
Allow WebSocket upgrades (101 Switching Protocols) — rejected with 502 by default |
retry.attempts |
number | 0 |
Number of retry attempts (0 = disabled) |
retry.conditions |
string[] | — | connection_error, 5xx, timeout |
retry.backoffMs |
number | 0 |
Wait between retries (ms) |
retry.backoffJitter |
bool | false |
±50% random spread on backoffMs to avoid thundering herd |
retry.budgetPercent |
number | — | Soft cap: max % of in-flight requests that may be retries |
healthCheck |
object | — | Active health probes — see Health checks |
backup |
string | — | Fallback URL when all primaries are unhealthy |
cache |
object | — | Response cache — see Proxy cache |
pool |
object | — | Connection pool — see Connection pool |
rateLimit |
object | — | Per-route rate limit — see Rate limiting |
upstreamTls |
object | — | TLS for HTTPS upstreams — see Upstream TLS |
mirror |
string | — | Shadow URL — see Traffic mirroring |
sticky.cookie |
string | — | Cookie name for sticky sessions |
sticky.secret |
string | — | HMAC-SHA256 secret — Conduit signs + verifies the cookie (prevents forgery) |
sticky.strict |
boolean | false |
Return 503 when the pinned upstream is down (instead of routing elsewhere) |
groups |
object[] | — | Two-level LB groups: [{name, targets, strategy}] |
groupStrategy |
string | round-robin |
Outer strategy when groups is set |
priority |
number (0–100) | 50 |
Request priority for load shedding — see Priority routing |
Routes
The routes array matches requests before proxy / static. First match wins.
# YAML
routes:
# Route to dedicated API v2 server — checked first
- match:
path: /api/v2/**
method: [GET, POST]
headers:
X-Version: "2"
proxy:
targets: ["http://v2-backend:4000"]
# Write operations go to write cluster
- match:
path: /api/**
method: [POST, PUT, PATCH, DELETE]
proxy:
targets: ["http://write-api:4001", "http://write-api:4002"]
strategy: least-conn
# Beta users via cookie → canary backend
- match:
cookies:
beta: "1" # exact: cookie beta=1
experiment: "blue|green" # regex: blue or green
proxy:
targets: ["http://canary:4000"]
# Everything else → SPA static files
- match:
path: /**
static: ./dist
// JSON
{
"routes": [
{
"match": {
"path": "/api/v2/**",
"method": ["GET", "POST"],
"headers": { "X-Version": "2" }
},
"proxy": { "targets": ["http://v2-backend:4000"] }
},
{
"match": {
"path": "/api/**",
"method": ["POST", "PUT", "PATCH", "DELETE"]
},
"proxy": {
"targets": ["http://write-api:4001", "http://write-api:4002"],
"strategy": "least-conn"
}
},
{
"match": { "path": "/**" },
"static": "./dist"
}
]
}
Each route entry has exactly three top-level fields: match, proxy, and static.
Auth, rate limiting, and other policies come from the site-level config and apply
to all routes uniformly.
match field |
Type | Description |
|---|---|---|
path |
glob | Path glob — see skipPaths glob syntax |
method |
string[] | HTTP methods to match (case-insensitive) |
headers |
object | Request headers that must match (exact or regex) |
query |
object | Query parameters that must match (exact or regex) |
cookies |
object | Cookies that must match (exact or regex) |
All headers, query, and cookies values are matched as full-string
regex (anchored ^…$). Plain strings like "v2" or "1" match exactly;
regex patterns like "blue|green" or "Bearer .+" use regex semantics. An
invalid regex falls back to exact-string comparison.
Load Balancing
Set strategy inside a proxy route. All strategies skip upstreams that are
currently unhealthy (failed health probes) or ejected (outlier detection).
proxy:
/api:
targets: ["http://a:4000", "http://b:4000"]
strategy: least-conn # pick a strategy
Choosing a strategy
The algorithm matters less than it looks — every strategy here already skips unhealthy/ejected upstreams (health checks, not the algorithm, are what keeps traffic off a broken backend). Pick based on your actual traffic shape:
| Strategy | Family | Picks based on | Session affinity | Pool change impact | Best for | Weak point |
|---|---|---|---|---|---|---|
round-robin (default) |
Static | Rotation order | ✗ | none | Homogeneous upstreams, simplest default | Ignores load — a slow request still gets the next slot |
weighted-round-robin |
Static | Rotation + fixed weight | ✗ | none | Heterogeneous capacity (big primary + small standby, canary %) | Weights are static — doesn’t adapt to real-time load |
random |
Static | Uniform random pick | ✗ | none | Very large pools where a counter is unnecessary overhead | Worse uniformity than round-robin at small N |
least-conn |
Load-aware | Fewest active connections | ✗ | none | Variable response times (mixed fast reads / slow writes) | O(N) scan of the pool on every request |
least-response-time |
Load-aware | Lowest probed latency | ✗ | none | Upstreams with different hardware/geography | Needs healthCheck configured, or it’s just round-robin |
p2c |
Load-aware | 2 random samples, fewer conns wins | ✗ | none | Large pools (10+) where least-conn’s full scan is too slow |
Marginally less precise than true least-conn |
ip-hash |
Affinity | hash(client IP) % N |
by IP | remaps most clients (~N/(N+1)) whenever the healthy pool size changes, incl. health ejections | Soft per-IP affinity without a cookie | Remaps most of the pool on any healthy-count change, not just config changes |
consistent-hash |
Affinity | hash(configurable key) % N |
by key | same as ip-hash — % N on the healthy count, incl. health ejections |
Per-tenant/per-user routing, cache locality by URL | Same remap caveat as ip-hash — it’s % N, not a hash ring |
Rule of thumb: start with round-robin. Move to least-conn or p2c once
request cost varies noticeably. Reach for ip-hash/consistent-hash only for
affinity, not performance — and prefer sticky.cookie over ip-hash when you
control the client, since cookie-based affinity survives healthy-pool-size
changes (including health ejections) that ip-hash’s % N doesn’t.
┌─────────────┐
client ──requests──▶ │ Conduit │
│ (strategy) │
└──────┬──────┘
┌───────────────────┼───────────────────┐
▼ ▼ ▼
upstream A upstream B upstream C
(healthy) (healthy) (ejected ✗ — skipped
by every strategy)
round-robin / weighted-rr / random → pick by rotation or chance
least-conn / least-response-time / p2c → pick by current load/latency
ip-hash / consistent-hash → pick by hash(key) % healthy-count
round-robin (default)
Cycles through the target list in order, one request at a time. A per-route atomic counter is incremented on each request and taken modulo the number of healthy upstreams.
Request 1 → a Request 2 → b Request 3 → a …
Use when: all upstreams are homogeneous (same hardware, same capacity). Simple, predictable, zero overhead.
proxy:
/api:
targets: ["http://a:4000", "http://b:4000", "http://c:4000"]
strategy: round-robin # or omit — this is the default
weighted-round-robin
Like round-robin, but each upstream gets a number of slots proportional to its
weight. If A has weight 3 and B has weight 1, the pattern is A, A, A, B per
four requests (exact interleaving may differ, but ratios are preserved over
time).
Use when: upstreams have different capacities — e.g. a beefy primary and a smaller standby, or a canary deployment receiving a fraction of traffic.
Targets must be { url, weight } objects — plain strings use weight 1.
proxy:
/api:
targets:
- { url: "http://primary:4000", weight: 9 } # 90% of traffic
- { url: "http://canary:4000", weight: 1 } # 10% canary
strategy: weighted-round-robin
// JSON
{
"proxy": {
"/api": {
"targets": [
{ "url": "http://primary:4000", "weight": 9 },
{ "url": "http://canary:4000", "weight": 1 }
],
"strategy": "weighted-round-robin"
}
}
}
Note:
conduit upstreams weightcan change weights at runtime without reloading the config.
least-conn
Routes each new request to the upstream with the fewest active in-flight connections at that instant. The connection counter is incremented before the request is forwarded and decremented when the response completes (including retries and errors).
Use when: requests have highly variable response times — e.g. a mix of fast reads and slow writes. Under uniform load it behaves like round-robin; its advantage emerges when some requests stall.
proxy:
/api:
targets: ["http://a:4000", "http://b:4000", "http://c:4000"]
strategy: least-conn
Tip: combine with
healthCheck.maxConnectionsPerUpstreamfor a circuit breaker that activates when all upstreams are saturated.
least-response-time
Routes each request to the upstream with the lowest measured latency from the most recent active health probe. Falls back to round-robin during the warm-up phase before any probe has completed.
Latency is measured by the health-check prober (HEAD request to healthCheck.path).
Upstreams without probe data are ranked last (u64::MAX).
Use when: upstreams have meaningfully different hardware or geographic
proximity and you want to bias traffic toward the fastest one. Requires
healthCheck to be configured on the route — without probes, this strategy
falls back to round-robin permanently.
proxy:
/api:
targets:
- "http://us-east:4000"
- "http://eu-west:4000"
strategy: least-response-time
healthCheck:
path: /health
intervalSecs: 10
// JSON
{
"proxy": {
"/api": {
"targets": ["http://us-east:4000", "http://eu-west:4000"],
"strategy": "least-response-time",
"healthCheck": { "path": "/health", "intervalSecs": 10 }
}
}
}
ip-hash
Hashes the client IP address (FNV-1a) and maps it to an upstream via
hash % pool_size. The same IP always hits the same upstream — as long as the
pool size doesn’t change.
Use when: you need soft session affinity without a session cookie, e.g. legacy apps that store state per-IP, or to concentrate logs from one user on one backend.
Caveat: adding or removing an upstream changes
pool_sizeand remaps roughly half of all clients. This ishash % N(modulo), not a consistent hash ring. Usesticky.cookiefor stable, cookie-based affinity.
proxy:
/auth:
targets: ["http://auth1:5000", "http://auth2:5000"]
strategy: ip-hash
hashKey: ip # default for ip-hash; can be omitted
// JSON
{
"proxy": {
"/auth": {
"targets": ["http://auth1:5000", "http://auth2:5000"],
"strategy": "ip-hash"
}
}
}
consistent-hash
Hashes a configurable hashKey (IP, URL, or any request header) and maps
it to an upstream via hash % pool_size. Identical to ip-hash in
implementation — the distinction is purely which value is hashed.
Use when: you want to route requests by tenant, user, or any other request attribute to a dedicated upstream.
The hashKey field controls what is hashed:
hashKey value |
Hashes | Example use case |
|---|---|---|
ip |
Client IP | Per-IP affinity |
url |
Full request URL | Cache-locality — same URL always hits same backend |
header:X-Name |
Value of header X-Name |
Per-tenant or per-user routing |
proxy:
/api:
targets:
["http://shard-1:4000", "http://shard-2:4000", "http://shard-3:4000"]
strategy: consistent-hash
hashKey: "header:X-Tenant-ID"
// JSON
{
"proxy": {
"/api": {
"targets": [
"http://shard-1:4000",
"http://shard-2:4000",
"http://shard-3:4000"
],
"strategy": "consistent-hash",
"hashKey": "header:X-Tenant-ID"
}
}
}
Same caveat as ip-hash: pool size changes remap a large fraction of keys. This is
hash % N, not a Karger consistent hash ring.
random
Selects an upstream uniformly at random on each request using a fast thread-local RNG.
Use when: you want an even distribution without any coordination overhead —
useful for very large pools where maintaining a round-robin counter per route
is unnecessary. In practice, round-robin is usually preferred since it
provides better uniformity over small sample sizes.
proxy:
/api:
targets: ["http://a:4000", "http://b:4000", "http://c:4000"]
strategy: random
p2c (Power of Two Choices)
On each request, samples two upstreams at random and forwards to the one with fewer active connections. Ties are broken in favour of the first sample.
Achieves O(log log N) maximum load imbalance — dramatically better tail latency
than pure random, and competitive with least-conn at scale, with O(1)
selection cost (no scan of the full pool).
With a single upstream in the pool, P2C falls back to round-robin.
Use when: the upstream pool is large (10+) and least-conn would scan the
entire list on every request. Also useful as a drop-in replacement for
least-conn when you want reduced coordination overhead.
proxy:
/api:
targets:
- "http://a:4000"
- "http://b:4000"
- "http://c:4000"
- "http://d:4000"
strategy: p2c
// JSON
{
"proxy": {
"/api": {
"targets": [
"http://a:4000",
"http://b:4000",
"http://c:4000",
"http://d:4000"
],
"strategy": "p2c"
}
}
}
Sticky sessions
Route a client to the same upstream for the duration of a session cookie, using consistent hashing on the cookie value.
proxy:
/app:
targets: ["http://a:4000", "http://b:4000"]
strategy: consistent-hash
sticky:
cookie: session_id
// JSON
{
"proxy": {
"/app": {
"targets": ["http://a:4000", "http://b:4000"],
"strategy": "consistent-hash",
"sticky": { "cookie": "session_id" }
}
}
}
If the client presents no cookie (first request), the request is routed by the configured strategy and the upstream is recorded — no cookie is set by Conduit. The application is responsible for setting the session cookie.
HMAC-signed sticky cookies
Without a secret, the cookie value is used as a raw consistent-hash key.
An attacker can craft a cookie to force routing to any backend they choose.
Set sticky.secret to make Conduit sign cookies with HMAC-SHA256 and
verify them on every request. Forged cookies silently fall back to normal
load-balancing rather than pinning to an attacker-controlled backend.
proxy:
/app:
targets: ["http://a:4000", "http://b:4000"]
strategy: consistent-hash
sticky:
cookie: srv_id # Conduit sets this cookie on every response
secret: "$STICKY_SECRET" # HMAC key — use an env var, never hardcode
strict: false # true = 503 when pinned upstream is down
| Option | Behaviour |
|---|---|
No secret |
Cookie value is the raw consistent-hash key (legacy, no forgery protection) |
secret set |
Conduit signs the URL with HMAC-SHA256 and injects a Set-Cookie on every response |
strict: false (default) |
If the pinned upstream is unhealthy, fall back to the next available backend |
strict: true |
If the pinned upstream is unhealthy, return 503 Service Unavailable immediately |
The injected cookie attributes are: Path=/; HttpOnly; SameSite=Lax.
How the pin is applied. With a secret set, the verified cookie names one
exact upstream, and Conduit routes to that upstream directly — the
configured strategy is not consulted at all while the pin can be honored.
A pin is honored whenever its upstream is healthy and below
maxConnectionsPerUpstream; otherwise Conduit falls back to the strategy and:
- pinned upstream saturated (but healthy) — relocate for this request and leave the cookie untouched, so the session returns to its original upstream as soon as capacity frees up;
- pinned upstream unhealthy — relocate and re-sign the cookie onto the new upstream, since the original is gone.
Without a secret, there is no pinned URL to honor — the cookie value is only
a hash key, and routing goes through consistent-hash as usual.
Security note: store the HMAC secret in an environment variable (
secret: "$STICKY_SECRET"). Rotate by changing the value and reloading; existing cookies will silently fall through to normal load-balancing for one request and then get a new signed cookie.
Upstream groups (two-level balancing)
An outer strategy picks the group; an inner strategy balances within it.
groups is an array — each entry has name, targets, and optional strategy.
proxy:
/api:
groups:
- name: us-east
targets: ["http://us-east-1:4000", "http://us-east-2:4000"]
strategy: least-conn
- name: eu-west
targets: ["http://eu-west-1:4000", "http://eu-west-2:4000"]
strategy: least-conn
groupStrategy: ip-hash # outer: same client IP always hits same region
// JSON
{
"proxy": {
"/api": {
"groups": [
{
"name": "us-east",
"targets": ["http://us-east-1:4000", "http://us-east-2:4000"],
"strategy": "least-conn"
},
{
"name": "eu-west",
"targets": ["http://eu-west-1:4000", "http://eu-west-2:4000"],
"strategy": "least-conn"
}
],
"groupStrategy": "ip-hash"
}
}
}
See examples/upstream-groups.yaml
Static Files
Simple directory
# YAML
static: ./dist
// JSON
{ "static": "./dist" }
Multiple directories
# YAML
static:
- ./dist
- ./public
// JSON
{ "static": ["./dist", "./public"] }
Path-mapped directories
# YAML
static:
/: ./dist
/docs: ./docs-dist
// JSON
{ "static": { "/": "./dist", "/docs": "./docs-dist" } }
Static options
# YAML
static: ./dist
staticOptions:
index: [index.html] # default files for directory requests
dotFiles: ignore # "ignore" | "allow" | "deny"
preCompressed: true # serve .br / .gz if present
etag: true # ETag / 304 Not Modified support
lastModified: true # Last-Modified header
maxAge: "1y" # Cache-Control max-age (humantime: "1d", "30m", "1y")
// JSON
{
"static": "./dist",
"staticOptions": {
"index": ["index.html"],
"dotFiles": "ignore",
"preCompressed": true,
"etag": true,
"lastModified": true,
"maxAge": "1y"
}
}
| Field | Type | Default | Description |
|---|---|---|---|
index |
string[] | ["index.html"] |
Default files for directory requests |
dotFiles |
string | "ignore" |
"ignore", "allow", or "deny" |
preCompressed |
bool | false |
Serve pre-compressed .br / .gz files |
etag |
bool | true |
ETag + conditional GET support |
lastModified |
bool | true |
Last-Modified header |
maxAge |
string | — | Cache-Control: max-age — humantime duration string |
Redirects
redirects is an array of redirect rules.
# YAML
redirects:
- from: /old-path
to: "https://example.com/new-path"
status: 301
- from: "/blog/(.+)" # regex capture group
to: "https://blog.example.com/$1"
status: 308
// JSON
{
"redirects": [
{
"from": "/old-path",
"to": "https://example.com/new-path",
"status": 301
},
{ "from": "/blog/(.+)", "to": "https://blog.example.com/$1", "status": 308 }
]
}
| Field | Type | Default | Description |
|---|---|---|---|
from |
string | — | Path or regex pattern to match |
to |
string | — | Destination URL (capture groups $1…$N are expanded) |
status |
number | 301 |
HTTP redirect status code |
Fallback
Return a response when no route matches.
# YAML — SPA: serve index.html for all unmatched browser routes
fallback:
file: ./dist/index.html
status: 200
# YAML — content-type-aware fallback (Accept header negotiation)
fallback:
byAccept:
html:
file: ./dist/index.html
status: 200
json:
body: { "error": "Not Found", "status": 404 }
status: 404
status: 404
body: "Not Found"
// JSON
{
"fallback": {
"file": "./dist/index.html",
"status": 200
}
}
// JSON
{
"fallback": {
"byAccept": {
"html": { "file": "./dist/index.html", "status": 200 },
"json": { "body": { "error": "Not Found", "status": 404 }, "status": 404 }
},
"status": 404,
"body": "Not Found"
}
}
| Field | Type | Default | Description |
|---|---|---|---|
file |
path | — | File to serve |
body |
any | — | Response body (string or JSON object) |
status |
number | 200 |
HTTP status code |
headers |
object | — | Response headers to set |
byAccept |
object | — | Content-type-aware rules keyed by Accept type (see key matching below) |
byAccept keys are matched against the request’s Accept header (the header value is
lowercased before matching; a configured key like xml is matched case-sensitively
against it, so a literal-substring key should be written in lowercase), checked in the
order they’re defined, first match wins:
| Key | Matches when Accept contains |
|---|---|
html |
text/html |
json |
application/json |
text |
text/plain or text/ |
image |
image/ |
| any other key | the key itself, as a literal substring (e.g. xml matches application/xml) |
* |
wildcard fallback, checked only after every other key has been tried |
Health Checks
Active upstream probes
# YAML
proxy:
/api:
targets: ["http://a:4000", "http://b:4000"]
healthCheck:
path: /health
intervalSecs: 10
unhealthyThreshold: 3
healthyThreshold: 1
slowStartSecs: 30
unhealthyStatus: [429, 500, 502, 503, 504] # treat these status codes as failures
unhealthyLatencyMs: 2000 # treat responses slower than 2s as failures
// JSON
{
"proxy": {
"/api": {
"targets": ["http://a:4000", "http://b:4000"],
"healthCheck": {
"path": "/health",
"intervalSecs": 10,
"unhealthyThreshold": 3,
"healthyThreshold": 1,
"slowStartSecs": 30,
"unhealthyStatus": [429, 500, 502, 503, 504],
"unhealthyLatencyMs": 2000
}
}
}
}
Site health endpoint (for load balancer probes)
# YAML
healthCheck: true
healthCheck:
path: /__health__
includeUpstreams: true # include per-upstream health in JSON response
// JSON
{ "healthCheck": { "includeUpstreams": true } }
Health check field reference
| Field | Type | Default | Description |
|---|---|---|---|
path |
string | /__health__ |
Probe URL path |
intervalSecs |
number | 10 |
Probe interval |
timeoutMs |
number | 2000 |
Probe timeout |
unhealthyThreshold |
number | 3 |
Consecutive failures before removal |
healthyThreshold |
number | 1 |
Consecutive passes before re-adding |
unhealthyStatus |
number[] | any non-2xx | HTTP status codes from the health-check probe that count as failures. Default: any non-2xx response. Example: [429, 500, 502, 503, 504] |
unhealthyLatencyMs |
number | — | Health-check probe responses slower than this (ms) count as failures, even if the status code is 2xx |
slowStartSecs |
number | 0 |
Traffic ramp-up period after recovery. Ignored for ipHash/consistentHash and sticky routes. |
maxConnectionsPerUpstream |
number | — | Circuit breaker threshold |
prewarmConnections |
number | 0 |
Pre-establish N keepalive connections at startup (max 8). 🚫 Blocked — warms a throwaway client, not Conduit’s real upstream pool (Pingora 0.9 has no public API for it) — see note below. |
includeUpstreams |
bool | false |
Include upstream health in /__health__ response |
Circuit Breaker
maxConnectionsPerUpstream enforces a per-upstream connection cap for every
load-balance strategy, across all three config shapes (proxy: {} map,
routes[] array, and groups):
- Requests are only routed to upstreams currently below the cap.
- When all healthy upstreams for a route are at or above the cap,
Conduit returns
503immediately instead of queuing. IpHash/ConsistentHash(and sticky sessions, which forceConsistentHash) don’t shrink the hash space when a peer is at capacity — they forward-probe to the next candidate on the ring. This means only the client(s) whose preferred peer is currently saturated get relocated (to a deterministic fallback peer); every other client’s mapping is unaffected. For sticky sessions specifically, a capacity relocation does not re-sign the session cookie — the client keeps its original pin and retries it on the next request, so the session genuinely returns to its original upstream once that upstream has capacity again, rather than permanently migrating to the fallback.- The cap is a soft limit under concurrency: it is checked, then
acquired, in two separate steps (not atomically), so a burst of
simultaneously-arriving requests can briefly overshoot it by the number of
concurrent racers. This self-corrects on the next request and is the same
trade-off
retry.budgetPercentmakes — a spurious503for a request that actually had capacity is worse than brief overshoot. - For a
groups-configured route, only the selected group’s targets are checked — if every target in that group is at capacity, the request 503s even if a different group has room. Group selection itself is not capacity-aware (it’s typically hash/affinity-driven). - A retry attempt re-checks the cap. The first attempt uses whatever peer
routing already chose (the usual strategy- and capacity-aware pick); each
retry attempt after that forward-probes the route’s retry candidate list,
starting from its position in the rotation, for the next peer currently
under the cap — skipping (not permanently removing) a saturated one,
the same forward-probe shape
IpHash/ConsistentHashalready use above. If every candidate is saturated, it fails open to the naive rotation target rather than 503ing a request that has already spent attempts — the same soft-cap trade-off as the rest of this section.
Known limitations still open after the 2026-08-03 integrity audit — see the repo’s issue tracker for current status:
prewarmConnectionsis blocked, not just unimplemented. It warms a short-lived, throwaway HTTP client rather than Conduit’s real upstream connection pool — Pingora 0.9 has no public API to reach or pre-populate the poolupstream_peer()actually uses for real traffic (HttpProxy’sclient_upstreamfield is private with no accessor). Same class of gap as OCSP stapling / the request-queue item below — waiting on a future Pingora release.
# YAML
proxy:
/api:
targets: ["http://a:4000", "http://b:4000", "http://c:4000"]
healthCheck:
maxConnectionsPerUpstream: 100
backup: "http://replica:4000"
// JSON
{
"proxy": {
"/api": {
"targets": ["http://a:4000", "http://b:4000", "http://c:4000"],
"healthCheck": { "maxConnectionsPerUpstream": 100 },
"backup": "http://replica:4000"
}
}
}
See examples/circuit-breaker.yaml
Slow start
healthCheck.slowStartSecs ramps traffic to an upstream that just recovered
from an unhealthy state, instead of sending it 100% of its normal share
immediately (the classic thundering-herd-into-a-just-recovered-backend
scenario).
For the slowStartSecs seconds immediately after recovery, the upstream
participates in each pick with a probability equal to how far through the
ramp window it is (0% right after recovery, rising linearly to 100% once the
window elapses). This applies to every load-balance strategy except
ipHash/consistentHash and sticky sessions:
RoundRobin,Random,WeightedRoundRobin,LeastConn,LeastResponseTime, andP2call honor it.LeastConnandLeastResponseTimebenefit the most — a freshly-recovered peer’s connection count and probe latency both look artificially good (drained to near-zero, or measured under no real load), so without this they’d win every pick until real traffic caught them up, which is itself a form of thundering herd.ipHash/consistentHash(and sticky sessions, which forceconsistentHash) are deliberately exempt. A client’s hash must map to a fixed upstream for the strategy’s own consistency guarantee to hold — a probabilistic ramp gate would divert some hashed clients to a different peer mid-window, breaking exactly the property those strategies exist to provide, and would flap a sticky client’s session for the entire ramp. ConfiguringslowStartSecstogether with a hash strategy orstickyon the same route logs a warning at config-load/reload time rather than silently doing nothing.- The ramp never produces a 503 and never empties an otherwise-routable candidate list (fails open): if every healthy candidate happens to be mid-ramp, or a route has only one upstream, the gate steps aside and normal selection proceeds. Two consequences worth knowing: a single-upstream route can’t ramp (there’s nowhere to shift traffic to), and if your entire upstream pool recovers at once (e.g. after a fleet-wide outage), full traffic resumes immediately rather than being shed — no admission scheme can shape load when there’s no non-ramping sibling to route to instead.
- Applies to retry rotation too — a retry-configured route won’t rotate a
failed request into a peer that’s still mid-ramp, on either the
proxy: {}map path or theroutes[]array path.
Recovery is recorded both when an active health-check probe flips an
upstream back to healthy, and when a half-open outlier-detection probe
succeeds — the latter is the only recovery signal available for upstreams
configured via routes[]/groups, since active probes aren’t spawned there.
# YAML — pairs well with least-conn, since that's the strategy slow-start
# benefits the most
proxy:
/api:
targets: ["http://a:4000", "http://b:4000"]
strategy: least-conn
healthCheck:
slowStartSecs: 30
// JSON
{
"proxy": {
"/api": {
"targets": ["http://a:4000", "http://b:4000"],
"strategy": "least-conn",
"healthCheck": { "slowStartSecs": 30 }
}
}
}
Retry
# YAML
proxy:
/api:
targets: ["http://a:4000", "http://b:4000"]
retry:
attempts: 3
conditions:
- connection_error
- "5xx"
- timeout
backoffMs: 100
backoffJitter: true # add ±50% random spread to backoffMs
budgetPercent: 20
timeout:
perTryMs: 2000
// JSON
{
"proxy": {
"/api": {
"targets": ["http://a:4000", "http://b:4000"],
"retry": {
"attempts": 3,
"conditions": ["connection_error", "5xx", "timeout"],
"backoffMs": 100,
"backoffJitter": true,
"budgetPercent": 20
},
"timeout": { "perTryMs": 2000 }
}
}
}
Retry field reference
| Field | Type | Default | Description |
|---|---|---|---|
attempts |
number | 0 |
Number of retry attempts (0 = disabled) |
conditions |
string[] | — | Triggers: "connection_error", "5xx", "timeout". Retries only happen when at least one matches. |
backoffMs |
number | 0 |
Fixed wait between attempts (ms). 0 = immediate retry. |
backoffJitter |
bool | false |
Apply ±50% random spread to backoffMs to avoid thundering herd. Effective delay: [ms/2, ms*3/2) |
budgetPercent |
number | — | Soft cap: at most this fraction of in-flight requests may be retries. Prevents retry storms under mass failure. |
Body buffering for retry — POST/PUT/PATCH requests are only retried when
limits.maxBodyBufferBytesis set. Without it, request bodies are not buffered andconnection_errorretries on non-GET methods are skipped silently.
1xx interim responses
Some upstream backends (Spring Boot, CDNs, gRPC gateways) send one or more
1xx informational responses before the final response — for example
103 Early Hints for browser resource preloading, or 100 Continue after a
Expect: 100-continue request header.
Conduit passes 1xx responses through to the client without running any middleware (retry logic, error masking, response transforms, etc.), then continues waiting for the real response. No configuration is required.
Exception: 101 Switching Protocols (WebSocket upgrade) is handled
separately by the websocket: true route option — see the field reference table.
Outlier Detection
Passively eject upstreams that return too many 5xx responses from real traffic.
Tracks and ejects for every load-balance strategy, not just LeastConn.
# YAML
outlierDetection:
consecutive5xx: 5
baseEjectionTimeSecs: 30
maxEjectionTimeSecs: 300
maxEjectionPercent: 33
// JSON
{
"outlierDetection": {
"consecutive5xx": 5,
"baseEjectionTimeSecs": 30,
"maxEjectionTimeSecs": 300,
"maxEjectionPercent": 33
}
}
Ejection uses exponential backoff: 30 s → 60 s → 120 s → … up to
maxEjectionTimeSecs.
Half-open circuit breaker: when the ejection period expires, the first
request is allowed through as a probe. If the probe succeeds (non-5xx), the
upstream is fully restored and ejection_count is reset. If it fails, the
upstream is re-ejected with the next backoff level. All other requests during
the probe are blocked until the probe completes.
| Field | Type | Default | Description |
|---|---|---|---|
consecutive5xx |
number | 5 |
Consecutive errors before ejection |
baseEjectionTimeSecs |
number | 30 |
Initial ejection duration |
maxEjectionTimeSecs |
number | 300 |
Maximum ejection duration (cap on backoff) |
maxEjectionPercent |
number | 10 |
Max % of cluster that may be ejected at once |
Limits
# YAML
limits:
maxBodyBytes: 10485760 # reject request bodies over 10 MB (413)
maxHeaderBytes: 65536 # reject headers over 64 KB
timeoutSecs: 30 # global request timeout (seconds)
maxInflightRequests: 1000 # return 503 when 1000 requests are in flight
maxBodyBufferBytes: 1048576 # buffer up to 1 MB per request for retry replay
maxConnectionsPerIp: 50 # max simultaneous connections from one IP (429)
keepaliveRequestLimit: 1000 # recycle connections after this many requests
priorityThreshold: 0.8 # shed low-priority routes above 80% concurrency
minUploadRateBytesPerSec: 1024 # reject uploads slower than 1 KiB/s (408)
// JSON
{
"limits": {
"maxBodyBytes": 10485760,
"maxHeaderBytes": 65536,
"timeoutSecs": 30,
"maxInflightRequests": 1000,
"maxBodyBufferBytes": 1048576,
"maxConnectionsPerIp": 50,
"keepaliveRequestLimit": 1000,
"priorityThreshold": 0.8,
"minUploadRateBytesPerSec": 1024
}
}
| Field | Type | Default | Description |
|---|---|---|---|
maxBodyBytes |
number | — | Max request body size — returns 413 if exceeded |
maxHeaderBytes |
number | — | Max request header size |
timeoutSecs |
number | — | Global request timeout |
maxInflightRequests |
number | — | Max concurrent requests — returns 503 if exceeded (must be ≥ 1) |
maxBodyBufferBytes |
number | — | Max body buffered per request for retry replay |
maxConnectionsPerIp |
number | — | Max simultaneous open connections from a single client IP — returns 429 if exceeded |
maxRequestHeaders |
number | — | Max number of request headers — returns 431 Request Header Fields Too Large if exceeded |
keepaliveRequestLimit |
number | — | Max requests per keepalive connection; closes and recycles after. Equivalent to nginx’s keepalive_requests. |
priorityThreshold |
number | 0.8 |
Fraction of maxInflightRequests at which low-priority routes are shed (0.0–1.0) — see Priority routing |
minUploadRateBytesPerSec |
number | — | Minimum upload rate in bytes/s — closes slow uploads with 408 Request Timeout (slow-loris protection) |
Priority Routing
Priority routing lets high-value routes continue to be served when the site is
under load, while low-priority routes are shed with 503 Load Shedding.
How it works
- Set
limits.maxInflightRequeststo cap total concurrency. - Set
limits.priorityThreshold(default0.8) — the fraction of the cap at which shedding begins. - Mark routes with
priority: 0–100(50= normal, omitted = normal). - When
inflight / maxInflightRequests ≥ priorityThreshold, any request whose effective priority is below 50 receives503 Load Shedding.
Requests with priority ≥ 50 (normal or high) are never shed by this
mechanism. The X-Priority: <0–100> request header can raise the
effective priority above the configured route value (useful for trusted
internal callers).
# YAML
limits:
maxInflightRequests: 2000
priorityThreshold: 0.8 # shed low-priority at 1600+ concurrent
routes:
- match:
path: /api/critical/**
proxy:
targets: [http://api:4000]
priority: 90 # always served
- match:
path: /api/batch/**
proxy:
targets: [http://api:4000]
priority: 10 # shed first when overloaded
- match:
path: /api/**
proxy:
targets: [http://api:4000]
# no priority → defaults to 50 (normal, not shed)
{
"limits": {
"maxInflightRequests": 2000,
"priorityThreshold": 0.8
},
"routes": [
{
"match": { "path": "/api/critical/**" },
"proxy": { "targets": ["http://api:4000"], "priority": 90 }
},
{
"match": { "path": "/api/batch/**" },
"proxy": { "targets": ["http://api:4000"], "priority": 10 }
}
]
}
| Field | Type | Default | Description |
|---|---|---|---|
limits.priorityThreshold |
number | 0.8 |
Load fraction at which shedding begins (0.0–1.0) |
proxy.*.priority |
number (0–100) | 50 |
Route priority; below 50 = sheddable |
Note: Priority routing only applies when both
maxInflightRequestsandpriorityThresholdare configured on the site.
Fault Injection
Requires
cargo build --features fault-injectionFor testing only — do not use in production.
# YAML
faultInjection:
abort:
percent: 5
status: 503
body: "Injected fault"
delay:
percent: 10
ms: 500
// JSON
{
"faultInjection": {
"abort": { "percent": 5, "status": 503, "body": "Injected fault" },
"delay": { "percent": 10, "ms": 500 }
}
}
Proxy Cache
Requires
cargo build --features cacheFor Redis-backed cache also add--features redis; for disk cache add--features disk-cache.
Size limit:
cache.maxSizeMbbounds the store with an LRU eviction manager (least-recently-used entries are purged once the total exceeds the budget). There is no per-body cap: a single response larger than the budget is written and then evicted again, because capping bodies without aContent-Lengthwould abort the client mid-transfer. Limits of the current implementation: the budget of a store is fixed by the first route that uses it (changingmaxSizeMbneeds a restart, not a reload), routes sharing astoreshare its budget, and fordisk:/redis://stores only entries admitted since the process started are counted, so entries left over from a previous run are not included until they are rewritten.
# YAML
proxy:
/api:
targets: ["http://backend:4000"]
cache:
store: memory
ttlSecs: 60
maxSizeMb: 256 # store budget (MiB), LRU eviction
staleWhileRevalidateSecs: 300 # serve stale up to 5 min while refreshing
staleIfErrorSecs: 600 # serve stale up to 10 min if upstream fails
varyHeaders: [Accept-Language, Accept-Encoding]
skipPaths: [/api/me, /api/cart] # never cache these paths
skipIfCookie: true
methods: [GET, HEAD]
// JSON
{
"proxy": {
"/api": {
"targets": ["http://backend:4000"],
"cache": {
"store": "memory",
"ttlSecs": 60,
"maxSizeMb": 256,
"staleWhileRevalidateSecs": 300,
"staleIfErrorSecs": 600,
"varyHeaders": ["Accept-Language", "Accept-Encoding"],
"skipPaths": ["/api/me", "/api/cart"],
"skipIfCookie": true,
"methods": ["GET", "HEAD"]
}
}
}
}
Cache field reference
| Field | Type | Default | Description |
|---|---|---|---|
store |
string | — | "memory", "redis://..." / "rediss://..." (--features redis), "disk:/path" (--features disk-cache) |
ttlSecs |
number | — | Fresh cache TTL (seconds) |
maxSizeMb |
number | — | Size budget of the store in MiB (LRU eviction) (≥ 1; unset = unlimited) |
staleWhileRevalidateSecs |
number | 0 |
Serve stale while refreshing in background (RFC 5861) |
staleIfErrorSecs |
number | 0 |
Serve stale when upstream returns 5xx, including after retries are exhausted (RFC 5861) |
earlyRefreshSecs |
number | 0 |
Refresh cache in the background when remaining TTL < this value (see below) |
varyHeaders |
string[] | — | Vary cache key by these request headers |
skipPaths |
string[] | — | Paths to never cache |
skipIfCookie |
bool | false |
Skip caching when request has a cookie |
methods |
string[] | [GET, HEAD] |
Cacheable HTTP methods |
Cache key — scheme + host + path + query string. Request body is never
part of the key, so POST responses are not cached by default (add "POST" to
methods only for idempotent endpoints). Use varyHeaders to differentiate
responses by Accept-Language or Accept-Encoding.
Upgrading from a Pingora 0.8 based release: the key is now built with an explicit boundary between the host and the rest, so every key hashes differently. A persistent store (
disk:orredis://) starts cold once; Redis entries expire on their own TTL, but the olddisk:files are never read again and are not removed automatically — delete the cache directory to reclaim the space.
Age header (RFC 7234 §5.1): Conduit automatically injects an `Age: