EQProxy Load balancer and reverse proxy in Rust

Configuration

All of EQProxy's configuration lives in a single TOML file with schema_version = 1. This page collects the keys that exist —type, default and note— per section, with a minimal example per block.

The real schema is eqproxy.schema.json, regenerable with eqproxy config schema; eqproxy.toml.example is the commented file that goes over the same keys.

File structure

The schema is closed (additionalProperties: false in every table): a key with a typo is rejected, not ignored. schema_version is mandatory and only 1 is valid. Durations are strings with a suffix ("500ms", "5s", "1h"). Relative paths —certificates, maps, error pages, on-disk cache— are resolved from the TOML's directory, not from the working directory.

Listener and backend names are table keys ([listener.web], [backend.api]). A listener requires listen and backend; a backend requires server or servers, never both.

Global and metrics

NameTypeDefaultNotes
schema_versioninteger— (required)Only accepted value: 1
shutdown_graceduration"10s"Draining of live connections when shutting down with SIGINT
[metrics] detailedbooleanfalsePer-server and per-route counters; reloadable
[metrics] max_seriesinteger128Series budget, 1..=4096; fixed at start
schema_version = 1
shutdown_grace = "10s"

[metrics]
detailed = false
max_series = 128

Listeners

NameTypeDefaultNotes
listenstring— (required)IP:PORT; port 0 asks for an ephemeral one
backendstring— (required)Fallback backend when no rule matches
modestring"tcp""tcp" (byte tunnel) or "http" (strict HTTP/1.1)
maxconninteger1024Concurrent connections; on reaching the cap the newcomer is rejected
connect_timeoutduration"5s"Connection to the backend
idle_timeoutduration"60s"Idleness per direction
client_header_timeoutduration"5s"http only: receiving the request header
server_header_timeoutduration"30s"http only: receiving the response header
max_stream_durationduration"0s"Cap on the total duration of the exchange; "0s" disables it
forwarded_headersstring"off""off", "append" or "replace"
proxy_receivebooleanfalseAccept PROXY protocol v1; requires proxy_allow with IPs or CIDR
quicbooleanfalseHTTP/3 over QUIC on the same IP:PORT over UDP; requires TLS and mode = "http"
ruleslist of tables[]Ordered rules; the first match wins
error_pageslist of tables[]Custom error pages, per code
local_responsetableabsentResponse of its own when no server is available
[listener.web]
listen = "0.0.0.0:8080"
mode = "http"
backend = "api"
maxconn = 2048
forwarded_headers = "append"

Timeouts

Each phase has its own clock and its observable response; config explain prints both before anything happens.

TimeoutWhat it measuresWhen it fires
connect_timeoutConnecting to the backend502
client_header_timeoutReceiving the request header408 on the first exchange; close afterwards
server_header_timeoutReceiving the response header504
idle_timeoutIdleness in one directionClose, also in WebSocket, CONNECT and tcp tunnels
max_stream_durationTotal duration of the exchange504 before the header; truncated stream afterwards
timeout_queueWaiting in the backend queueExplicit 503
health_check.timeoutOne check attemptCounts as a failure

Idleness and duration are different things: idle_timeout cuts an exchange that does not move; max_stream_duration, one that moves but lasts too long.

Connections and limits

NameTypeDefaultNotes
listener.maxconninteger1024Cap on the listener's concurrent connections
servers[].maxconninteger00..=1000000; 0 = only the listener limits it; on reaching the cap the balancer stops choosing it
limits.max_head_bytesinteger163841024..=1048576; applies to the request and response header
limits.max_headersinteger1001..=1000 headers per message
limits.max_body_bytesinteger00 = no limit; 413 is answered before connecting if the length is declared
limits.stream_buffer_bytesintegerabsentPipe of each h2/h3/WebSocket stream: 1024..=1048576; absent = 64 KiB (16 KiB on h3)
[listener.web.limits]
max_head_bytes = 16384
max_headers = 100
max_body_bytes = 1048576

When every server of a backend is at its maxconn, the request fails explicitly with no_available_server and, if there is a queue, waits in it.

TLS on the listener

NameTypeDefaultNotes
certpath— (required)PEM chain, leaf first
keypath— (required)PEM key: PKCS#8, PKCS#1 or SEC1
alpnlist[]For example ["h2", "http/1.1"]; empty = no ALPN; "h3" is rejected here
min_versionstring"1.2"TLS 1.0 and 1.1 are never accepted
max_versionstringabsent (= 1.3)"1.2" limits to TLS 1.2; it cannot end up below min_version
client_capathabsentPresent = mTLS: the client certificate must chain to this CA
certslist of tables[]Additional certificates per SNI: cert, key and names
[listener.web.tls]
cert = "certs/fullchain.pem"
key = "certs/privkey.pem"
alpn = ["h2", "http/1.1"]

[[listener.web.tls.certs]]
cert = "certs/api.pem"
key = "certs/api.key"
names = ["api.example.com", "*.api.example.com"]

The default certificate answers clients without SNI and the names no entry claims: an unknown name does not make the handshake fail. Selection is exact name and then the longest wildcard.

Routing rules

Each rule in [listener.*.rules] adds conditions (AND) and carries exactly one action; the array order is the evaluation order.

NameTypeDefaultNotes
namestring"rule-N"Name for explain and logs
host / host_prefix / host_regexstring—Host without port, case-insensitive
path / path_prefix / path_regexstring—Not normalized: /v1 also matches /v10
methods, headers, cookies, iplist[]Headers and cookies with exact, regex or present = true
maptableabsent{ name, key or header, equals }: queries a [maps.*]
use_backendstring—Action: send to the backend
splitlist—Action: weighted split [{ backend, weight }]
redirecttable—Action: local 3xx with status and location
direct_responsetable—Action: local body with status, body and content_type
rewritetable—Only with use_backend: path_prefix_rewrite, host_rewrite
set_headers / add_headers / del_headerslist[]Only with use_backend; values with $remote_addr, $host, $scheme
response_headerslist[]Headers the client sees, with the same templates
rate_limittableabsent{ rate, per, burst }; bucket per client IP and per rule
corstableabsentallow_origin required; the preflight is answered locally
authtableabsenturl (http:// subrequest) and/or type = "basic" with htpasswd users
[[listener.web.rules]]
name = "api-v1"
host = "api.example.com"
path_prefix = "/api/v1/"
use_backend = "api"
rate_limit = { rate = 10, per = "1s", burst = 20 }

[listener.web.rules.rewrite]
path_prefix_rewrite = "/api/"

A 429 from rate limiting is always explicit and adds rate_limited. The bucket is local to the process: with several instances, each one keeps its own.

Upstreams and balancing

NameTypeDefaultNotes
serverstring—A single server (IP:PORT, HOSTNAME:PORT or unix:/path); mutually exclusive with servers
serverslist of tables—addr, weight, backup, priority, maxconn, source
balancestring"roundrobin""roundrobin" (smooth weighted) or "leastconn"
slow_startduration"0s"Ramp of the effective weight after returning to operational; maximum "1h"
all_backupsbooleanfalseSpreads across all backups instead of one tier at a time
resolve_intervalduration"30s"Re-resolution of named servers; "0s" = only at start
proxy_protocolstring"none""v1" or "v2" towards the server
maxqueueinteger0Bounded queue when no server is available; 0 = reject right away
timeout_queueduration"5s"Maximum wait in that queue
retriesinteger00..=5 connection retries
retry_non_idempotentbooleanfalseAllows retrying POST and the like
affinitytableabsentmode = "cookie" (cookie eqproxy_affinity by default) or "source"
[backend.*.tls]tableabsentverify (true), ca_file, server_name, alpn, min_version

In servers, weights range from 0 to 256 and weight = 0 leaves the server drained without touching the rest. priority (0..=255) only works with backup = true and orders the failover tiers; all_backups and priority together are a configuration error. A source sets the local outbound IP and only works for TCP destinations.

[backend.api]
balance = "leastconn"
servers = [
  { addr = "10.0.0.11:9000", weight = 3 },
  { addr = "10.0.0.12:9000", weight = 1 },
  { addr = "10.0.0.13:9000", weight = 1, backup = true },
]
maxqueue = 256
timeout_queue = "500ms"

Health checks

NameTypeDefaultNotes
typestring"tcp""tcp" (connect only) or "http" (a real exchange)
intervalduration"2s"Between cycles
timeoutduration"1s"Per attempt: connection, request and response
fall / riseinteger3 / 2Consecutive failures to go down; successes to come back
passivebooleanfalseLive traffic (5xx, connection failures) feeds the same state machine
http_path / http_methodstring"/" / "GET"Only type = "http"; the method, GET or HEAD
http_hoststringthe server's addressHost header of the probe
expect_statusstring"200-299"Accepted codes or ranges
expect_rstringstringabsentRegex that must appear in the body (read up to 64 KiB)
[backend.api.health_check]
type = "http"
interval = "2s"
fall = 3
rise = 2
http_path = "/healthz"
expect_status = "200-299"

Without a health_check section, all servers are considered operational. A correct check does not prove the application works: it tests what you ask of it and nothing more.

Cache

Only in mode = "http" and only if the section exists.

NameTypeDefaultNotes
default_ttlduration"60s"1s..=24h; s-maxage/max-age only shorten it
max_memory_bytesinteger671088641 MiB..=1 GiB; LRU eviction on exceeding it
max_entry_bytesinteger104857664 KiB..=64 MiB and never larger than the memory
disk_pathpathabsentOn-disk persistence; without it, memory only
[listener.web.cache]
default_ttl = "60s"
max_memory_bytes = 67108864
disk_path = "cache/web"

Only GET with status 200, 301, 308, 404 and 410 and without Set-Cookie is stored. Served responses carry X-Cache: HIT, MISS or REVALIDATED.

To compress responses there is a separate section, [listener.*.compression]: gzip and level (1..9, 6 by default), brotli and brotli_quality (0..11, 5 by default), min_size (1024) and types.

WASM filters

A request filter, one per listener, only in mode = "http". The module is compiled when the configuration is loaded: a missing file, a denied import or a missing export reject the snapshot, they do not fail per request.

NameTypeDefaultNotes
pathpath— (required).wasm module, relative to the configuration file
fuelinteger100000001..=100000000 instructions per request
timeoutduration"50ms"1ms..=5s, with 1 ms resolution
memory_bytesinteger167772161 MiB..=256 MiB of linear memory per instance
max_header_opsinteger161..=64 header mutations per request
on_errorstring"closed""closed" answers 503; "open" forwards unchanged and counts the error
[listener.web.plugin]
path = "plugins/filter.wasm"
timeout = "50ms"
on_error = "closed"

The plugin can add, replace or remove request headers and reject with 400..=599; the framing ones (content-length, transfer-encoding, host) are reserved.

Data maps

NameTypeDefaultNotes
filepathabsentOne key value pair per line; # only at the start; duplicate keys are rejected
entriestableabsentInline, static entries; mutually exclusive with file
defaultstringabsentValue for keys with no entry
reload_intervalduration"5s"Re-reading of the file; "0s" = load once
[maps.tiers]
file = "tiers.map"
default = "free"

[[listener.web.rules]]
name = "vip-tenants"
map = { name = "tiers", header = "x-tenant", equals = "vip" }
use_backend = "vip"

A map is re-read hot without reloading the configuration: the new data enters atomically and the rest of the state —counters, buckets, pools, connections— survives. Write with write + rename so the watcher never reads a half-written file; a malformed update is rejected, adds map_reload_failures and keeps serving the last good one.

Secrets and hot reload

SecretWhereReloadable with SIGHUP
Certificates and TLS keys[listener.*.tls] and certs[].cert/keyYes, and with POST /api/v1/certificates/reload
Client CA (mTLS)client_caYes; on a live QUIC listener, no
CA towards the backend[backend.*.tls] ca_fileYes
Read and write tokentoken_file, write_token_fileNo: they are set at start
Mode, group, ACL and allowlist of the Unix socket[admin] unix_socket_*Yes, on the already bound socket
[admin]
unix_socket = "/run/eqproxy/admin.sock"
unix_socket_mode = "0660"
unix_socket_gid = 20
token_file = "/etc/eqproxy/admin.token"
write_token_file = "/etc/eqproxy/admin-write.token"

Prefer token_file to token: a token written in the TOML travels wherever the file is copied. The write token must differ from the read one; without it, the whole plane is read-only.

Admin plane

NameTypeDefaultNotes
listenstringabsentIP:PORT; loopback unless allow_remote. Mutually exclusive with unix_socket
unix_socketpathabsentUnix socket; mode 0600 by default
unix_socket_modestring"0600"Only "0600" or "0660"; with 0660 unix_socket_gid is required
unix_socket_allow_uidslist[]Allowed peer uids, checked with SO_PEERCRED/getpeereid
unix_socket_acl_uidslist[]Users with rw in the file's POSIX ACL; Linux only, requires 0660 and gid
token / token_filestring / pathabsentRead token; at least one
write_token / write_token_filestring / pathabsentWrite token; without it there are no writes
allow_remotebooleanfalseAllows a non-loopback bind; the token travels in the clear (the plane has no TLS)
panelbooleanfalseServes the web panel at GET /panel; off, all of /panel is 404
[admin]
listen = "127.0.0.1:9001"
token_file = "/etc/eqproxy/admin.token"
write_token_file = "/etc/eqproxy/admin-write.token"
panel = true

Without an [admin] section there is no admin plane. The endpoints, roles and response codes are in Admin API.