The goal of this project: a cheap, fast way to filter invalid requests before they reach your API/backend — not another general-purpose reverse proxy to replace nginx.
rproxy sits behind nginx (or any other front-end web server/load balancer). The front end keeps terminating TLS, serving static files, etc.; rproxy receives the already-proxied traffic and decides what earns the right to reach the backend. The filters are the whole point, everything else is secondary:
- JWT authentication per route/domain/global, with the signature-check result cached.
- IP allow/deny (route → domain → global), including a rule for requests with no matched route.
- Rate limiting per IP per route (per-second sliding window).
- Request validation: method / headers / query / cookie / form-body — rules from config, with a configurable response on rejection.
- Path canonicalisation before any decision (route match, filters, JWT, cache key): closes
ACL-bypass differentials like
/x/../admin,//admin,/%61dminbetween what rproxy evaluates and what the backend later resolves. - HTTP request-smuggling defenses: strict Content-Length/Transfer-Encoding parsing, a 400 on ambiguous framing, a 400 on duplicate/missing Host — rejected outright, never silently forwarded.
On top of the filters you get what you'd expect from a reverse proxy: Host/path routing, 7 load
balancing algorithms, an L7 response cache, active health checks, Prometheus metrics. Under the
hood: monoio (io_uring), a prefork / thread-per-core architecture, a zero-alloc hot path,
backend state and metrics in shared memory (MAP_SHARED) with no locks — so the filtering itself
stays cheap even at high throughput.
The point of putting a filter here at all is to give traffic policy between the front end and the backend one place to live, instead of it being scattered across services — which also means infra and security teams get an actual way to shape that traffic (who's allowed in, at what rate, under what conditions) without depending on a backend team to ship a code change for it.
Today that policy usually ends up in one of three places, each with its own cost:
- Inside application code. Every service reinvents its own auth/rate-limit/validation logic, in whatever language it happens to be written in. There's no single place to review or change the policy — a security engineer wanting a new deny rule needs a PR into someone else's codebase.
nginx.conf. nginx can express most of this, but its directive language —locationblocks, the well-known surprises ofifinsidelocation, module-specific directives spread across included files — is its own DSL that takes real nginx expertise to change safely. An infra engineer adjusting a timeout, or a security engineer adding an IP rule, is one misplaced directive away from an outage or a silent bypass.- A service mesh or API gateway (Istio, Envoy, Kong, ...) solves this properly at real scale — but at real cost: a control plane, CRDs or an admin API/DB of its own, sidecars per pod, and a learning curve that puts day-to-day changes out of reach for anyone who isn't a specialist in that specific tool.
rproxy's answer is deliberately boring: the whole policy is one flat, cascading YAML file —
listen, domains, routes, backends, filters, all sharing the same global → domain → route →
backend inheritance (see Parameter inheritance). No custom DSL, no CRDs,
no admin API, nothing to reimplement per service. Anyone who already writes YAML for Kubernetes,
Ansible, or CI can read the current policy and safely change it — which is what actually lets
infra and security participate in it, rather than filing a ticket and waiting.
To make that concrete, not just asserted — three tiers of what "properly configuring nginx" actually runs into:
Impossible without third-party code, full stop. Open-source nginx has no directive that
verifies an HMAC-signed JWT — auth_jwt is nginx Plus (paid) only; anything else means embedding
njs or Lua (OpenResty) and maintaining that as real code, with no built-in caching of the
verification result the way rproxy caches a signature check bound to the secret's hash. Likewise,
active health checks (proactively probing a path/interval, independent of live traffic) need
nginx Plus or a compiled third-party module (ngx_http_upstream_check_module) — stock config has
only passive max_fails/fail_timeout. Rendezvous (HRW) hashing, which only remaps the departed
node's share of keys on a backend change, isn't a stock directive either.
Possible, but only by leaving "config" for scripting. A validation rule with its own custom
status/body on failure means chaining if inside location with return/error_page — nginx's
own docs call if in that position a known source of surprises. A sliding-window rate limit that
inherits through the same global → domain → route cascade as every other filter means juggling
separately-declared limit_req_zone blocks (nginx's own algorithm there is leaky-bucket, not
sliding-window) instead of one inheritance model covering every filter type.
Possible, but easy to get quietly wrong — which is not hypothetical here. This project's own
history has a bug in exactly this class: an early version matched route/ACL rules against the
raw request path, while the backend resolved dot-segments and percent-escapes on its own —
/x/../admin, //admin, /%61dmin all bypassed a deny_ip rule on /admin this way. The fix
was normalizing the path once, before routing, ACL, JWT, and the cache key all see it, and
forwarding backends the same canonical form that was evaluated. Reproducing that guarantee in
nginx means hand-assembling rewrite/if/merge_slashes with no structural assurance that
normalization happens exactly once and identically for every one of those decisions — the same
"one misplaced directive away from a silent bypass" risk from above, not a strawman.
The only source of truth for these numbers is
bench/BENCHMARK_RESULTS.md
(2026-06-27, bare metal, median of 3 runs after warmup, <2% spread):
| Scenario | req/s | avg lat | vs nginx |
|---|---|---|---|
| Bench ceiling (backend, no proxy) | 362,163 | 292 µs | — |
| nginx reverse proxy | 153,484 | 662 µs | 1.00× |
| rproxy | 189,810 | 557 µs | 1.24× |
Conditions: i5-12450H (12 logical cores), wrk -t4 -c100 -d10s keep-alive on cores 4-7, an nginx
backend answering "200 OK" (2-byte body) on cores 0-1, the proxy on cores 2-3, 2 workers each for
rproxy and nginx (equal cores).
Three-proxy comparison on the same rig (measured 2026-08-16,
bench/haproxy_baseline.sh, nginx backend, wrk -t4 -c100 -d10s, median of 3;
the nginx proxy uses keep-alive to the backend — without it nginx opens a TCP
connection per request and the numbers would be misleading):
| Proxy | req/s | avg lat | vs ceiling | vs nginx |
|---|---|---|---|---|
| Bench ceiling (nginx, no proxy) | 632,175 | 157 µs | 1.00× | — |
| rproxy | 323,035 | 352 µs | 0.51× | 1.15× |
| nginx reverse proxy | 282,032 | 400 µs | 0.45× | 1.00× |
| haproxy 2.8.5 (vendored build) | 266,603 | 395 µs | 0.42× | 0.95× |
rproxy beats haproxy by ~21% and nginx-as-reverse-proxy by ~15% on this rig (nginx and
haproxy are nearly tied here). TLS (self-signed, bench/tls_bench.sh): ceiling 629,459 req/s →
rproxy TLS 134,168 req/s (~21% of ceiling — the cost of TLS termination).
Scope of these numbers: a small keep-alive response (the case where head+body coalescing helps most), a single backend on the same box, no TLS. Larger bodies, no keep-alive, or under TLS may look different. rproxy = 52% of the rig's HTTP ceiling, i.e. the proxy is the bottleneck, not the rig.
HAProxy was not measured on the current rig. A comparison config exists in bench/haproxy.cfg,
but there is no bare-metal baseline for it, so no HAProxy comparison is claimed here.
- Key features
- Architecture
- Build and run
- Configuration
- Configuration examples
- Security and limits
- Proxy response codes
- Metrics
- Production checklist
- What's NOT implemented
- Repository documents
- io_uring via
monoio— all worker network I/O. - Zero-lock / zero-alloc hot path — reusable buffers (
PooledBuf), state inthread_local!, no mutexes. Allocations only happen on rare paths (e.g. a non-canonical request-target — seenormalize_path). - Shared-memory IPC (
MAP_SHARED) — backend liveness and metric counters are visible to every process with no locking; health checks and/metricspolling never contend with traffic. - Rendezvous hashing (HRW) for
iphash/urlhash— losing a node only moves that node's share of keys. - WebSocket /
Upgrade— a bidirectional tunnel after101 Switching Protocols, including bytes that arrived before the upgrade. - Keep-alive on both sides — backends get a connection pool (
backend_pool_size) with a transparent one-shot retry if the pool handed back a connection the backend had already closed. - Protocol defenses — refuses ambiguous framing (smuggling), Slowloris-resistant timeouts, path canonicalisation before ACL matching, QoS shedding under overload.
graph TD
Client((Client)) -->|HTTP/1.1 / WS| W1[Worker 1<br/>monoio/io_uring]
Client -->|HTTP/1.1 / WS| W2[Worker N<br/>monoio/io_uring]
subgraph "Master Process"
M[Master / Supervisor] -->|fork| W1
M -->|fork| W2
M -->|fork| HM[Health & Metrics<br/>tokio]
end
W1 -.->|read status| SM[(Shared Memory<br/>MAP_SHARED)]
W2 -.->|read status| SM
HM -->|write status| SM
W1 -->|proxy| B1[Backend 1]
W2 -->|proxy| B2[Backend N]
HM -.->|active HTTP check| B1
HM -.->|active HTTP check| B2
- Master reads the config, initializes shared memory, creates the listening sockets, then
forks the health/metrics process and N workers. After that it only waits (
waitpid) and restarts crashed children — no traffic flows through it. - Health/metrics process — a separate fork with its own tokio runtime: active HTTP backend checks and the HTTP metrics endpoint. Workers have no tokio inside them at all.
- Workers — single-threaded
monoioprocesses:accept(connection balancing done by the kernel viaSO_REUSEPORT), HTTP parsing, routing, byte proxying.
Every worker is independent: rate_limit counters, the LRU cache, and the backend connection pool
are per-worker (see the caveats below).
Requires Linux with io_uring (kernel 5.6+, 5.11+ recommended).
cargo build --release
# validate the config and exit (exit 0 = ok, exit 1 = error; CI-friendly)
./target/release/rproxy -t -c /path/to/rproxy.yml
# run
./target/release/rproxy -c /path/to/rproxy.yml| Flag | Meaning |
|---|---|
-c <path> |
Path to the config. Defaults to rproxy.yml in the current directory. |
-t |
Validate the config only (syntax is ok / test is successful), don't bind ports. Exit code: 0 on success, 1 on a load error. |
A special build with per-cycle profiling (build_upstream_head, build_response_head,
jwt_claim_u64): cargo test --release --features cycle_profile cycle_profile_report -- --ignored --nocapture prints count/min/p50/mean/p99/max in TSC ticks per function plus the raw
values of the last 1024 calls to each (see src/cycles.rs). The cycle_profile feature is off by
default and never reaches a normal build (verified via a byte-for-byte .text/.data/.bss and
symbol-table comparison). The tick counts are TSC ticks, not literal core cycles, and the method
only works for synchronous functions with no internal .await — an async function that yields
partway through would show scheduler wait time, not its real CPU cost.
- Format: YAML (
serde_yaml). - Environment-variable substitution:
${VAR}and$VARare replaced with the variable's value; an unset variable becomes an empty string. Exception:$client_ipis not touched — it's a runtime macro forset_headers. - Unknown keys are silently ignored (serde without
deny_unknown_fields). A typo in a key name will not raise an error — it just won't take effect. Verify important values via the startup log and-t.
| Key | Type | Default | Description |
|---|---|---|---|
listen |
list of "ip:port" |
["0.0.0.0:8080"] |
HTTP sockets. |
tls_listen |
list of "ip:port" |
[] |
HTTPS sockets (needs tls_cert/tls_key). |
listen_iface |
string | — | Bind sockets to an interface (SO_BINDTODEVICE). A bad name is a startup failure. |
tls_cert / tls_key |
path | — | Default certificate and key. |
workers |
string | "auto" |
Worker count; "auto" = number of available cores. Capped at 256 (more is clamped). A non-numeric value → WARNING + auto. |
balance |
string | roundrobin |
Default load-balancing algorithm (see Load balancing). |
default |
list of backends | — | Backends used when no domain/route matched. |
health |
string | — | Default health-check spec (see Health checks). |
client_timeout |
seconds | 60 |
Client read/write timeout (Slowloris defense). |
connect |
seconds | 5 |
Backend connect timeout. |
response |
seconds | 30 |
Max gap between reads of the backend response. |
max_body_size |
bytes | 2097152 (2 MiB) |
Request body limit; over it → 413. |
max_headers_size |
bytes | 8192 |
Request header size limit; over it → 431. |
max_active_requests |
count | 10000 |
Hard ceiling on concurrent requests per worker; over it → 503. Always active, independent of QoS. |
normalize_path |
bool | true |
Canonicalise the request-target before routing (see below). |
reject_encoded_slash |
bool | false |
Reject an encoded path separator (see below). |
allow_ip / deny_ip |
list of CIDRs | — | Client-address allow/deny list; inherited by domains and routes. |
jwt_secret |
string | — | HMAC secret to verify Authorization: Bearer <JWT>; checks the signature, exp, and nbf. Invalid → 401. |
cache |
block | — | Response cache (see Response cache). |
cache_max_bytes |
bytes | 104857600 (100 MiB) |
Cache budget per worker (LRU). Total memory ≈ cache_max_bytes × workers. |
set_headers |
map | — | Headers added to the request forwarded to the backend. Values support $client_ip and ${ENV}. |
backend_pool_size |
count | 16 |
Keep-alive connection pool size to a backend per worker. 0 disables reuse. |
metrics_listen |
"ip:port" |
— | Address of the metrics endpoint (runs in a separate process). |
metrics_path |
path | /metrics |
Endpoint path. |
metrics_token |
string | — | Authorization: Bearer <token>; compared in constant time. |
metrics_basic_auth |
"user:password" |
— | HTTP Basic; a 401 carries WWW-Authenticate. |
log_level |
string | info |
none/off, error, warn, info, debug, trace. Per-request access logging happens at debug (at info the hot path allocates nothing). |
cpu_affinity |
auto/on/off |
auto |
Pin workers to cores. auto enables it only when there are more free cores than workers and no cgroup oversubscription. |
sqpoll |
auto/on/off |
auto |
io_uring SQPOLL. auto = off: measurements didn't confirm a win, and it burns a core for the poller. |
sqpoll_idle_ms |
count | 1000 |
Idle timeout of the SQPOLL thread, if enabled. |
worker_lifetime |
seconds | off | Recycle a worker by lifetime (±25% jitter, so workers don't restart in lockstep). |
worker_max_requests |
count | off | Recycle a worker after it has served this many requests. |
worker_drain |
seconds | max(response, 30) + 5 |
Cap on graceful drain before exiting during a recycle. While draining, a worker accepts no new connections and answers Connection: close. Set this explicitly for long-lived streams (WebSocket/SSE). |
Brings the request-target to a canonical form before routing and filters, and forwards that same form to the backend:
- unreserved percent-escapes are decoded (RFC 3986 §6.2.2.2):
/%61dmin→/admin; ./..and empty segments are removed (§6.2.2.3 / §5.2.4):/x/../admin→/admin,//admin→/admin; you can't climb above the root (/../..→/);- a trailing slash is preserved (
/a/b/.→/a/b/,/a/b/..→/a/); - the query and fragment are left alone;
OPTIONS *is untouched.
Why: otherwise /x/../admin won't match a match: /admin rule, while the backend would still
resolve it to /admin — meaning route-level deny_ip/allow_ip/jwt/rate_limit/validate can
be bypassed. A fragment in the request-target (# anywhere) is rejected with 400 — RFC 7230
§5.3 doesn't allow one, and otherwise /pub#/../admin would bypass the rule the same way.
%2F/%5C are left encoded — decoding them would invent a segment boundary the client never
sent. If your upstream decodes them itself before resolving .., enable reject_encoded_slash.
normalize_path: false restores byte-exact forwarding and reopens this ACL bypass.
Answers 400 when the path part carries a separator this proxy treats as an ordinary
character but an upstream might not: %2F/%2f, %5C/%5c, and a raw \ (not part of
pchar per RFC 3986 §3.3, but parsers accept it, and IIS/Windows backends treat it as a
separator).
Off by default because it breaks legitimate users of encoded slashes (S3 keys, git/registry refs,
encoded IDs). The query is not checked — ?redirect_uri=…%2F… keeps working. Double encoding
(%252F) is not caught: it requires two decode passes on the upstream side.
domains:
- match: "^api\\.example\\.com$" # regex OR a literal (see below)
routes: [...]
default: [...] # domain backends when no route matched| Key | Description |
|---|---|
match |
Literal (no regex metacharacters) → exact match against Host. Otherwise, a regex. The port in Host is stripped: match: "localhost" matches localhost:8080. On multiple matches, the longest pattern wins. |
routes |
List of routes (see below). |
default |
The domain's default backends. |
balance, health, connect, response, client_timeout, max_body_size, allow_ip, deny_ip, jwt_secret, cache, set_headers, log_level, backend_pool_size |
Override the global values for this domain. |
listen, tls_listen |
Restrict the domain to specific sockets. |
tls_cert, tls_key, tls_sni |
Certificate for this domain. For a literal match, the SNI name is derived automatically; for a regex match, tls_sni: ["host1", ...] is required. |
routes:
- match: "/api/" # literal → path prefix; regex → full regex
method: "GET,POST"
header: "X-Canary"
absent_header: "X-Internal"
query: "id=int|str,!debug"
cookie: "session,!guest"
post: "action=enum(save,delete)"
backends: [...]| Key | Type | Description |
|---|---|---|
match |
string | Literal → matches by path prefix (/api matches /api and /api/users, but not /v1/api). Otherwise, a regex. The longest pattern wins. |
method |
comma-separated list | Allowed methods (case-insensitive). |
header |
comma-separated list | Required headers; !name requires its absence. |
absent_header |
comma-separated list | Headers that must not be present. |
query |
conditions | Conditions on query params: name (present), !name (absent), name=int, name=str, name=enum(a,b), name=literal (multiple values separated by |). All conditions are ANDed. |
cookie |
conditions | Same, for cookies. |
post |
conditions | Same, for an application/x-www-form-urlencoded body. A mismatch → 403 (the body is fully buffered for this). |
backends |
list | Route backends. Falls back to the domain/global default if unset. |
validate |
list of rules | See below. |
rate_limit |
requests/sec | Limit per worker (see the caveat below); over it → 429. |
drop_threshold |
0–100 | cpu_load threshold for QoS shedding; enables QoS. Over the threshold or max_active_requests → 503. |
allow_ip, deny_ip, jwt_secret, cache, set_headers, connect, response, client_timeout, max_body_size, log_level, backend_pool_size, balance |
Override the domain's values. |
Filters select the route (a mismatch falls through to the next one), while post, validate,
allow_ip/deny_ip, jwt, rate_limit reject the request once a route is already selected.
validate:
- type: header # header | cookie | query | post
name: "X-Api-Key"
regex: "^[a-f0-9]{32}$" # optional: without it, only presence is checked
invert: false # true = the rule fires on a MATCH
on_fail:
status: 418 # defaults to 403
body: "nope\n" # defaults to "Forbidden\n"
backends: [...] # optional: route to a different backend instead of an error responseA literal regex (no metacharacters) is compared for exact equality; otherwise the regex
engine runs.
Two forms:
default:
- "127.0.0.1:8081" # short: host:port
- host: "10.0.0.5:8080" # detailed
weight: 3 # for balance: weighted (defaults to 1)
connect: 2 # override the connect timeout (sec)
response: 60 # override the response timeout (sec)
- host: "https://internal-svc:8443" # https:// = real TLS to the backend
tls_skip_verify: true # for self-signed/internal certs (see below)The backend's key is host (not addr). A missing host in the detailed form is a config
load error (Backend missing 'host' field).
An https:// prefix on a backend enables real upstream TLS (monoio_rustls::TlsConnector,
certificate verification against the system trust store via rustls-native-certs). SNI comes from
host. Any TLS error (untrusted cert, wrong SNI, handshake timeout) is treated as an ordinary
connect error — the backend is skipped/retried, ultimately a 502; there is no fallback to
plaintext.
For self-signed/internal certs not in the system trust store: tls_skip_verify: true on the
backend (detailed form only — the short host:port string can't carry this flag) disables
certificate-chain verification for that backend. The handshake signature is still verified
(proves the peer holds the certificate's private key) — only the chain/name check is skipped. Using
it without an https:// prefix is a config load error (the flag would be a no-op). Using it at all
logs a WARN at startup. Important: the health checker (health.rs) uses the same flag for its
own HTTP client — otherwise a backend with tls_skip_verify under health: would get full
verification in the health probe, get marked DOWN, and never serve traffic despite the flag.
| Value | Behavior |
|---|---|
roundrobin (aliases round_robin, rr) |
Round-robin. Default. |
random |
A random live backend. |
first |
Always the first live one (fallback mode). |
leastconn |
The one with the fewest active connections (counted per worker). |
weighted |
Proportional to weight. |
iphash |
HRW (rendezvous) on the client IP — session affinity. |
urlhash |
HRW on the request path. |
An unknown value is a config load error. iphash/urlhash use a built-in FNV-1a 64 hash
(implemented in src/balancer.rs), stable across Rust versions: the key→backend mapping is a
contract, not a toolchain property. Within one config, the mapping depends on backend order (ids
are assigned in declaration order), so changing the backend list reshuffles keys.
A space-separated key=value string; runs as an HTTP GET from a separate process:
health: "path=/health interval=10 timeout=5 rise=2 fall=3"| Parameter | Default | Meaning |
|---|---|---|
path |
/ |
Request path. |
interval |
5 |
Check period, seconds. |
timeout |
2 |
Check timeout, seconds. |
rise |
2 |
Consecutive successes to flip to UP. |
fall |
3 |
Consecutive failures to flip to DOWN. |
The spec is inherited: route → domain → global. There is no TCP-only check — it's always an HTTP request.
cache:
ttl: 60 # required, seconds
max_size: 1048576 # max size of a single response, bytes (defaults to 1 MiB)
respect_headers: false # honor the backend's Cache-Control/max-ageProperties: an LRU per worker with a cache_max_bytes budget; the key is method + Host +
canonical path; requests carrying Authorization neither read nor write the cache; responses
with Set-Cookie/Vary are never stored; the cache is bypassed on Upgrade.
TLS termination happens on tls_listen with tls_cert/tls_key. For a domain with its own
certificate, the SNI name is derived from a literal match; if match is a regex, set tls_sni
explicitly, or the certificate won't be registered under any name. There is a fallback to the
default certificate.
The cascade: global → domain → route → backend. connect, response, client_timeout,
max_body_size, balance, health, allow_ip, deny_ip, jwt_secret, cache, log_level,
backend_pool_size are all inherited. connect/response can additionally be overridden on the
backend itself.
Every example below is verified via rproxy -t.
listen:
- "0.0.0.0:8080"
workers: "auto"
default:
- "127.0.0.1:8081"listen:
- "0.0.0.0:8080"
workers: "4"
balance: "iphash"
# all timeouts are IN SECONDS
client_timeout: 10
connect: 2
response: 5
max_active_requests: 30000 # per-worker ceiling; over it → 503
domains:
- match: "app.example.com"
routes:
# QoS turns on via a route's drop_threshold:
# cpu_load > 85% OR max_active_requests exceeded → 503
- match: "/"
drop_threshold: 85
rate_limit: 5000 # per worker → globally ≈ 5000 × workers
backends:
- "192.168.1.10:80"listen: ["0.0.0.0:8080"]
tls_listen: ["0.0.0.0:443"]
tls_cert: "global.crt"
tls_key: "global.key"
set_headers:
X-Real-IP: "$client_ip"
X-Env: "${DEPLOY_ENV}"
domains:
- match: "^api\\.example\\.com$"
tls_sni: ["api.example.com"] # regex match → SNI must be given explicitly
listen: ["0.0.0.0:8080"]
health: "path=/healthz interval=5 fall=2"
routes:
- match: "^/v1/"
method: "GET,POST"
backends:
- host: "10.0.0.5:8080"
weight: 2
- host: "10.0.0.6:8080"
balance: "weighted"
- match: "/admin"
deny_ip: ["0.0.0.0/0"] # closed to everyone
backends:
- "10.0.0.5:8080"
default:
- "10.0.0.9:8080"- Framing / smuggling. A duplicate
Content-Length,CLalongsideTE, a coding list inTransfer-Encoding, a non-numericCL— all of this is ambiguous framing, rejected with400. Exactly one canonicalTransfer-Encoding: chunkedis set toward the backend. - Host. A duplicate
Hostor its absence on HTTP/1.1 →400(RFC 7230 §5.4). A missing Host is allowed on HTTP/1.0. - Request-target. Only origin-form (
/path) and*; absolute-form and authority-form →400. A fragment (#) →400. Path canonicalisation: seenormalize_path. - Hop-by-hop.
Connection,Keep-Alive,Upgrade,TE,Trailer,Transfer-Encoding,Proxy-Authenticate,Proxy-Authorizationare stripped both directions (except on a legitimate upgrade). - Slowloris. Every read/write is wrapped in a timeout (
client_timeout,connect,response). - Limits.
max_headers_size→431,max_body_size→413,max_active_requests→503(per worker, always on). - JWT. The HMAC signature,
exp, andnbfare checked;iatis informational. The verification cache is keyed to a hash of the secret (otherwise a token valid under one secret would pass under another). - Cache and privacy. A request carrying
Authorizationis never served from, or written to, the cache; responses withSet-Cookie/Varyare never stored. /metrics. Authentication is off by default. Setmetrics_tokenand/ormetrics_basic_auth, or bindmetrics_listento a private interface. The token and Basic auth are checked independently: either one is enough if both are set.
| Parameter | Actual value |
|---|---|
rate_limit |
≈ rate_limit × workers globally. A WARNING with the real figure prints at startup and on -t. |
max_active_requests |
per worker |
cache_max_bytes |
per worker → RAM ≈ cache_max_bytes × workers |
backend_pool_size |
per worker → connections to a backend ≈ pool × workers |
leastconn |
counts only its own worker's connections |
| Code | When the proxy sends it |
|---|---|
400 |
Ambiguous framing (CL/TE), duplicate or missing Host, absolute/authority-form, a fragment in the path, or — with reject_encoded_slash — an encoded separator. |
401 |
An invalid JWT (signature, exp, nbf, structure). |
403 |
deny_ip/allow_ip, a post-condition mismatch, validate (status configurable via on_fail). |
413 |
Body larger than max_body_size. |
429 |
rate_limit exceeded (per worker). |
431 |
Headers larger than max_headers_size. |
502 |
No live backends, couldn't connect, or the backend closed the connection in a way that isn't safe to retry. |
503 |
QoS shedding (drop_threshold) or max_active_requests exceeded. |
metrics_listen: "127.0.0.1:9090"
metrics_path: "/metrics"
metrics_token: "<bearer-token>" # Authorization: Bearer <token>, constant-time comparison
metrics_basic_auth: "user:password" # HTTP BasicPrometheus format, read directly from shared memory:
| Metric | Meaning |
|---|---|
rproxy_requests_total |
Requests broken down by response status. |
rproxy_bytes_total{dir="rx"|"tx"} |
Bytes from clients / to clients. |
rproxy_active_connections |
Active requests (summed across workers). |
rproxy_cpu_load |
CPU load used by QoS (sampled from /proc/stat). |
rproxy_backend_up |
Liveness of each backend (from the health checker). |
rproxy_backend_active_connections |
Active connections per backend. |
rproxy_qos_dropped_total |
Dropped by QoS. |
rproxy_rate_limit_dropped_total |
Rejected by rate_limit. |
rproxy_ip_dropped_total |
Rejected by allow_ip/deny_ip. |
rproxy_jwt_dropped_total |
Rejected by JWT verification. |
rproxy_rule_dropped_total |
Rejected by post/validate rules. |
- Run
rproxy -t -c <config>in CI — exit code1means the config won't load. - Verify the important keys actually took effect (unknown keys are silently ignored) — check the startup log and any WARNINGs.
metrics_listenon a private interface andmetrics_token/metrics_basic_auth.- Divide the global limits you want by the worker count (
rate_limit), and work outcache_max_bytes × workersagainst available RAM. - Set
health— without it, backends are always considered live. - For
https://backends, the certificate must be in the system trust store, or you get a 502; for self-signed/internal ones, usetls_skip_verify: truedeliberately (it drops the chain/name check for that backend). - Leave
normalize_path: true; if your upstream decodes%2Fitself, enablereject_encoded_slash. - For long-lived streams (WebSocket/SSE) with worker recycling enabled, raise
worker_drain. - Choose
max_body_sizeandmax_headers_sizedeliberately — the defaults (2 MiB / 8 KiB) are small.
- Graceful config reload (
SIGHUP) — a config change requires a restart. - Graceful drain on
SIGTERM— a plainSIGTERMto the master correctly terminates the whole process tree now (workers and the health/metrics process included, not left as orphans), and stops respawning instead of restarting crashed children forever. What's still missing is the graceful half: workers exit on the signal's own terms rather than finishing in-flight requests first — that drain logic already exists for worker recycling (worker_lifetime/worker_max_requests), just not wired to SIGTERM yet. client_ip_headeras a standalone directive — useset_headers: {X-Real-IP: "$client_ip"}instead.- HTTP/2 and HTTP/3 — HTTP/1.1 only (and HTTP/1.0 on the inbound side).
- Dual-stack (IPv4+IPv6) verification — the socket family is chosen from the
listenaddress; running both simultaneously hasn't been separately verified.
Current performance measurements: bench/BENCHMARK_RESULTS.md.