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
| Name | Type | Default | Notes |
|---|---|---|---|
schema_version | integer | — (required) | Only accepted value: 1 |
shutdown_grace | duration | "10s" | Draining of live connections when shutting down with SIGINT |
[metrics] detailed | boolean | false | Per-server and per-route counters; reloadable |
[metrics] max_series | integer | 128 | Series budget, 1..=4096; fixed at start |
schema_version = 1
shutdown_grace = "10s"
[metrics]
detailed = false
max_series = 128
Listeners
| Name | Type | Default | Notes |
|---|---|---|---|
listen | string | — (required) | IP:PORT; port 0 asks for an ephemeral one |
backend | string | — (required) | Fallback backend when no rule matches |
mode | string | "tcp" | "tcp" (byte tunnel) or "http" (strict HTTP/1.1) |
maxconn | integer | 1024 | Concurrent connections; on reaching the cap the newcomer is rejected |
connect_timeout | duration | "5s" | Connection to the backend |
idle_timeout | duration | "60s" | Idleness per direction |
client_header_timeout | duration | "5s" | http only: receiving the request header |
server_header_timeout | duration | "30s" | http only: receiving the response header |
max_stream_duration | duration | "0s" | Cap on the total duration of the exchange; "0s" disables it |
forwarded_headers | string | "off" | "off", "append" or "replace" |
proxy_receive | boolean | false | Accept PROXY protocol v1; requires proxy_allow with IPs or CIDR |
quic | boolean | false | HTTP/3 over QUIC on the same IP:PORT over UDP; requires TLS and mode = "http" |
rules | list of tables | [] | Ordered rules; the first match wins |
error_pages | list of tables | [] | Custom error pages, per code |
local_response | table | absent | Response 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.
| Timeout | What it measures | When it fires |
|---|---|---|
connect_timeout | Connecting to the backend | 502 |
client_header_timeout | Receiving the request header | 408 on the first exchange; close afterwards |
server_header_timeout | Receiving the response header | 504 |
idle_timeout | Idleness in one direction | Close, also in WebSocket, CONNECT and tcp tunnels |
max_stream_duration | Total duration of the exchange | 504 before the header; truncated stream afterwards |
timeout_queue | Waiting in the backend queue | Explicit 503 |
health_check.timeout | One check attempt | Counts 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
| Name | Type | Default | Notes |
|---|---|---|---|
listener.maxconn | integer | 1024 | Cap on the listener's concurrent connections |
servers[].maxconn | integer | 0 | 0..=1000000; 0 = only the listener limits it; on reaching the cap the balancer stops choosing it |
limits.max_head_bytes | integer | 16384 | 1024..=1048576; applies to the request and response header |
limits.max_headers | integer | 100 | 1..=1000 headers per message |
limits.max_body_bytes | integer | 0 | 0 = no limit; 413 is answered before connecting if the length is declared |
limits.stream_buffer_bytes | integer | absent | Pipe 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
| Name | Type | Default | Notes |
|---|---|---|---|
cert | path | — (required) | PEM chain, leaf first |
key | path | — (required) | PEM key: PKCS#8, PKCS#1 or SEC1 |
alpn | list | [] | For example ["h2", "http/1.1"]; empty = no ALPN; "h3" is rejected here |
min_version | string | "1.2" | TLS 1.0 and 1.1 are never accepted |
max_version | string | absent (= 1.3) | "1.2" limits to TLS 1.2; it cannot end up below min_version |
client_ca | path | absent | Present = mTLS: the client certificate must chain to this CA |
certs | list 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.
| Name | Type | Default | Notes |
|---|---|---|---|
name | string | "rule-N" | Name for explain and logs |
host / host_prefix / host_regex | string | — | Host without port, case-insensitive |
path / path_prefix / path_regex | string | — | Not normalized: /v1 also matches /v10 |
methods, headers, cookies, ip | list | [] | Headers and cookies with exact, regex or present = true |
map | table | absent | { name, key or header, equals }: queries a [maps.*] |
use_backend | string | — | Action: send to the backend |
split | list | — | Action: weighted split [{ backend, weight }] |
redirect | table | — | Action: local 3xx with status and location |
direct_response | table | — | Action: local body with status, body and content_type |
rewrite | table | — | Only with use_backend: path_prefix_rewrite, host_rewrite |
set_headers / add_headers / del_headers | list | [] | Only with use_backend; values with $remote_addr, $host, $scheme |
response_headers | list | [] | Headers the client sees, with the same templates |
rate_limit | table | absent | { rate, per, burst }; bucket per client IP and per rule |
cors | table | absent | allow_origin required; the preflight is answered locally |
auth | table | absent | url (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
| Name | Type | Default | Notes |
|---|---|---|---|
server | string | — | A single server (IP:PORT, HOSTNAME:PORT or unix:/path); mutually exclusive with servers |
servers | list of tables | — | addr, weight, backup, priority, maxconn, source |
balance | string | "roundrobin" | "roundrobin" (smooth weighted) or "leastconn" |
slow_start | duration | "0s" | Ramp of the effective weight after returning to operational; maximum "1h" |
all_backups | boolean | false | Spreads across all backups instead of one tier at a time |
resolve_interval | duration | "30s" | Re-resolution of named servers; "0s" = only at start |
proxy_protocol | string | "none" | "v1" or "v2" towards the server |
maxqueue | integer | 0 | Bounded queue when no server is available; 0 = reject right away |
timeout_queue | duration | "5s" | Maximum wait in that queue |
retries | integer | 0 | 0..=5 connection retries |
retry_non_idempotent | boolean | false | Allows retrying POST and the like |
affinity | table | absent | mode = "cookie" (cookie eqproxy_affinity by default) or "source" |
[backend.*.tls] | table | absent | verify (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
| Name | Type | Default | Notes |
|---|---|---|---|
type | string | "tcp" | "tcp" (connect only) or "http" (a real exchange) |
interval | duration | "2s" | Between cycles |
timeout | duration | "1s" | Per attempt: connection, request and response |
fall / rise | integer | 3 / 2 | Consecutive failures to go down; successes to come back |
passive | boolean | false | Live traffic (5xx, connection failures) feeds the same state machine |
http_path / http_method | string | "/" / "GET" | Only type = "http"; the method, GET or HEAD |
http_host | string | the server's address | Host header of the probe |
expect_status | string | "200-299" | Accepted codes or ranges |
expect_rstring | string | absent | Regex 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.
| Name | Type | Default | Notes |
|---|---|---|---|
default_ttl | duration | "60s" | 1s..=24h; s-maxage/max-age only shorten it |
max_memory_bytes | integer | 67108864 | 1 MiB..=1 GiB; LRU eviction on exceeding it |
max_entry_bytes | integer | 1048576 | 64 KiB..=64 MiB and never larger than the memory |
disk_path | path | absent | On-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.
| Name | Type | Default | Notes |
|---|---|---|---|
path | path | — (required) | .wasm module, relative to the configuration file |
fuel | integer | 10000000 | 1..=100000000 instructions per request |
timeout | duration | "50ms" | 1ms..=5s, with 1 ms resolution |
memory_bytes | integer | 16777216 | 1 MiB..=256 MiB of linear memory per instance |
max_header_ops | integer | 16 | 1..=64 header mutations per request |
on_error | string | "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
| Name | Type | Default | Notes |
|---|---|---|---|
file | path | absent | One key value pair per line; # only at the start; duplicate keys are rejected |
entries | table | absent | Inline, static entries; mutually exclusive with file |
default | string | absent | Value for keys with no entry |
reload_interval | duration | "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
| Secret | Where | Reloadable with SIGHUP |
|---|---|---|
| Certificates and TLS keys | [listener.*.tls] and certs[].cert/key | Yes, and with POST /api/v1/certificates/reload |
| Client CA (mTLS) | client_ca | Yes; on a live QUIC listener, no |
| CA towards the backend | [backend.*.tls] ca_file | Yes |
| Read and write token | token_file, write_token_file | No: 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
| Name | Type | Default | Notes |
|---|---|---|---|
listen | string | absent | IP:PORT; loopback unless allow_remote. Mutually exclusive with unix_socket |
unix_socket | path | absent | Unix socket; mode 0600 by default |
unix_socket_mode | string | "0600" | Only "0600" or "0660"; with 0660 unix_socket_gid is required |
unix_socket_allow_uids | list | [] | Allowed peer uids, checked with SO_PEERCRED/getpeereid |
unix_socket_acl_uids | list | [] | Users with rw in the file's POSIX ACL; Linux only, requires 0660 and gid |
token / token_file | string / path | absent | Read token; at least one |
write_token / write_token_file | string / path | absent | Write token; without it there are no writes |
allow_remote | boolean | false | Allows a non-loopback bind; the token travels in the clear (the plane has no TLS) |
panel | boolean | false | Serves 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.