torana

Configuration

TOML, one file, validated at startup and on every reload.

Invalid configs exit non-zero at startup and are rejected (keeping the running config) on reload. Fields marked planned parse without error so configs stay forward-compatible, but torana logs a warning rather than silently ignoring them.

FieldStatusNotes
global.log_levelenforcederror · warn · info (default) · debug · trace
global.log_formatenforcedjson (default) or plain text
global.metrics_addrenforcedmetrics + health listener, default 127.0.0.1:9090
global.workersignoredTokio sizes its own thread pool
listener.addrenforcedsocket address; binds fail fast at startup
listener.protocolenforcedhttp or https
listener.tls_cert / tls_keyenforcedPEM paths; required when protocol = https unless acme is set
listener.tls_client_caenforcedmTLS: CA PEM path; client certs become mandatory; not combinable with acme
listener.acme.domains/contact_emails/cache_direnforced*automatic HTTPS, RFC 8555, *needs --features acme
listener.acme.directory_url/staging/ca_certenforced*target a private ACME server or LE staging, *needs --features acme
route.nameenforcedidentifier used in logs and metrics
route.hostenforcedexact hostname, or "*.example.com" for subdomains; unset matches any host
route.pathenforcedprefix match on segment boundaries; unset matches any path
route.upstream[].addrenforcedhttp://host:port; https upstreams are rejected
route.upstream[].weightenforcedrelative integer weight, default 100, max 10,000
route.retriesenforcedmax attempts for GET/HEAD/OPTIONS with no body; default 2 with multiple upstreams
route.health_check.*enforcedpath/interval/timeout; absent = always healthy
route.pluginenforced*WASM request filter path, *needs --features plugins
route.timeout.totalenforcedduration to upstream response headers, default 30s
route.timeout.connect / first_byteplannedonly total is enforced
route.whenplannedreserved for a future CEL-based matcher; use host/path today
route.headers.request / .responseenforcedper-direction overrides; empty value removes the header
route.mirror.addr / .rateenforcedfire-and-forget; GET/HEAD/OPTIONS with no body only

Durations

Duration strings accept ms, s, and m: "500ms", "30s", "5m". A bare number is rejected; units are mandatory.

Weights

Weights are relative, reduced by their greatest common divisor: 90 / 10 produces the same schedule as 9 / 1. An upstream with no weight gets 100. The scheduler interleaves upstreams rather than sending long runs to one host.

Routing

Routes are matched in config order: first match wins. Put specific host/path combinations first and a constraint-free route last as a catch-all. A request that matches no route gets 404.

host + path routing
[[route]]
name = "api"
host = "api.example.com"
path = "/v1"
upstream = [{ addr = "http://127.0.0.1:3000" }]
retries = 2

[route.health_check]
path = "/healthz"
interval = "10s"
timeout = "2s"

[[route]]              # catch-all, no host/path, put it last
name = "default"
upstream = [{ addr = "http://127.0.0.1:4000" }]

Retries

Only GET, HEAD, and OPTIONS requests with an empty body are retried, and only on a connect-level failure: a request that reached the upstream is never retried, so a non-idempotent request is never double-executed. Retried attempts skip upstreams already tried this request and prefer upstreams the health check considers up.

Next: mTLS, header rewriting & mirroring, sandboxed WASM plugins, or HTTP/2 & automatic HTTPS.