An nginx-style edge server in Zig 0.17, on libxev and quic-zig. No C dependencies beyond libc.
- HTTP/1.1 server: keep-alive, pipelining, chunked bodies, strict parsing (request smuggling defenses), header/body limits, timeouts, per-IP connection limits.
- HTTP/3 over QUIC (quic-zig), on the same event loop as everything else,
with
Alt-Svcadvertised on the TCP side. - WebTransport relay: sessions are terminated and re-opened on an HTTP/3 upstream, with streams and datagrams paired one to one.
- TLS 1.3 on TCP (quic-zig's sans-IO
tls_server): SNI certificate selection, ALPN, session resumption. - Automatic certificates from Let's Encrypt or any ACME CA (RFC 8555, HTTP-01), renewed and swapped in without dropping connections.
- Static files: ranges, ETag / Last-Modified conditional requests, index
files, directory redirects, path normalization,
try_filesfallbacks for single-page apps. Opened and read on I/O threads, so a slow disk stalls only the requests reading from it; what the page cache holds is served straight from the worker, with an open-file cache and, over plain HTTP,sendfile. - Reverse proxy to HTTP/1.1 upstreams, plain or over TLS 1.3 (quic-zig's
sans-IO
tls_client, with optional certificate verification and client certificates): streaming in both directions with backpressure, keep-alive connection pools, retries of replayable requests, connect/read timeouts, WebSocket (Upgrade) tunnels, X-Forwarded-* headers. - Load balancing: round robin, least connections, IP hash; passive failure tracking and active HTTP health checks.
- Layer-4 TCP proxy for protocols routez doesn't terminate, and a layer-4 UDP proxy for QUIC traffic, with QUIC-LB connection-ID routing.
- Worker threads with SO_REUSEPORT. Graceful shutdown on SIGINT/SIGTERM:
keep-alive connections close once idle, after the client has been sent
every byte of its last response (queued output and
sendfileranges included); HTTP/3 connections get GOAWAY and finish their requests. All within a 10 s limit, after which what is left is closed. - Reload on SIGHUP: new workers start on the new config beside the old ones, taking over their TCP listening sockets, and the old ones drain. A config that fails to load is rejected and the running one kept.
- Locations matched as nginx does: exact paths, longest prefix (optionally
ending the search, like
^~) and regular expressions, case-sensitive or not, on a linear-time engine, so no pattern can be turned into a ReDoS. Regex groups feedproxy_passURIs, redirects and header values. rewriterules at server and location level with nginx's flags (last,break,redirect,permanent) and query-string handling.- Compression: precompressed
.br,.zstand.gzfiles served in place of the original (nginxgzip_static), chosen by the client'sAccept-Encodingq-values, and on-the-fly gzip for text-like responses. - Per location:
add_headers,proxy_set_headers(set, replace, remove, override Host) andlimit_req(per-client token bucket, optionally a named zone shared by locations). - Redirects and header values built from the request with nginx-style
variables (
$host,$request_uri, ...). - Access control: IP allow/deny rules, Basic auth against htpasswd files (bcrypt checked off the event loop), and client certificates (mutual TLS) over TLS and HTTP/3, with the client's identity in variables.
- Trusted proxies: behind a load balancer, the client's address comes from
X-Forwarded-For(nginx'sreal_ip_from) or the PROXY protocol (v1 and v2), for IP rules, limits, variables and logs alike. - Operations: drops root after binding (
user,group), access and error log files reopened on SIGUSR1 for rotation, access log formats (nginx'scombined, JSON lines, or a template of variables), a runtime log level, Prometheus metrics andstub_status-style counters. - Certificates are checked against their keys at load (
-ttoo).
zig build -Doptimize=ReleaseFast
./zig-out/bin/routez routez.zon # -t checks the config and exitsbuild.zig.zon depends on ../quic-zig by path.
On arm64, check that the build has AES. Zig 0.16 read some CPUs as generic
without it (Apple silicon inside a Linux VM, for one), and TLS then encrypted
in software, about 10× slower serving a 10 KB file; 0.17 finds AES there. If
-Dcpu=native still lacks it, build with -Dcpu=native+aes+sha2.
A ZON file; see src/config.zig for every field and default.
.{
.workers = 4,
.servers = .{.{
.listen = .{
.{ .port = 80 },
.{ .port = 443, .tls = true, .quic = true },
},
.server_names = .{ "example.com", "*.example.com" },
.tls = .{ .cert = "fullchain.pem", .key = "privkey.pem" },
.locations = .{
.{ .prefix = "/", .root = "/var/www" },
.{ .prefix = "/api/", .proxy_pass = "backend", .strip_prefix = true,
.proxy_set_headers = .{.{ .name = "x-env", .value = "prod" }},
.limit_req = .{ .rate = 50, .burst = 100 } },
.{ .prefix = "/assets/", .root = "/var/www", .gzip = true,
.add_headers = .{.{ .name = "cache-control", .value = "max-age=3600" }} },
.{ .prefix = "/health", .@"return" = .{ .body = "ok\n" } },
.{ .prefix = "/status", .stub_status = true },
.{ .prefix = "/metrics", .metrics = true },
.{ .prefix = "/wt/", .webtransport_pass = "10.0.0.5:4433" },
},
}},
.upstreams = .{.{
.name = "backend",
.servers = .{ "10.0.0.1:8080", "10.0.0.2:8080" },
.balance = .least_conn,
.health = .{ .path = "/healthz", .interval_ms = 5000 },
}},
.tcp_proxies = .{.{
.address = "0.0.0.0",
.port = 5432,
.proxy_pass = "db",
}},
.udp_proxies = .{.{
.port = 4433,
.proxy_pass = "game",
.quic_lb = .{ .server_id_len = 2, .server_ids = .{ "0001", "0002" } },
}},
}-
A location has exactly one of
root,proxy_pass,return,stub_status,metricsorwebtransport_pass, and matches byprefix,exactorregex(see Locations). -
proxy_passmay name a URI after the upstream, as in nginx:.{ .prefix = "/api/", .proxy_pass = "backend/v2/" }sends/api/xas/v2/x;strip_prefix = trueis the same as"backend/". A URI with variables replaces the whole path and query:.{ .regex = "^/u/(\\d+)$", .proxy_pass = "backend/users?id=$1" }. -
webtransport_passapplies to WebTransport CONNECTs over HTTP/3. QUIC upstream certificates are not verified unless the upstream setstls_verifyortls_ca. -
An upstream with
.tls = trueis reached over HTTPS (TLS 1.3 only). As with nginx'sproxy_ssl_verify off, its certificate is not checked unless the upstream setstls_verify = true(system CA store) ortls_ca = "ca.pem". Verification checks the chain, the host name and the handshake signature;tls_server_nameoverrides the name used for SNI and the check, which is otherwise each server's host. A server given as an IP address is matched against the certificate's IP addresses and gets no SNI. A failed handshake counts as a failed connect: the request moves to the next server, or gets a 502. Health checks use TLS too.tls_client_certandtls_client_keygive a certificate to present when the upstream asks for one (mutual TLS), in health checks too, and to WebTransport (QUIC) upstreams; the key may be EC P-256, Ed25519 or RSA. Both are read at start and on every reload, and-tchecks that the key belongs to the certificate..{ .name = "api", .servers = .{"10.0.0.7:443"}, .tls = true, .tls_verify = true, .tls_server_name = "api.internal.example", .tls_client_cert = "/etc/routez/api-client.pem", .tls_client_key = "/etc/routez/api-client.key" }, -
rootfollows nginx semantics: the full request path is appended. -
try_files = .{ "$uri", "$uri/", "/index.html" }on arootlocation serves the first entry that is a file:$uriis the request path, an entry ending in/means that directory'sindex. The last entry is the fallback, served for any path, or a status such as"=404". Unlike nginx, the fallback is a file under the sameroot, not a new request routed through the locations. -
Static files are opened, stat-ed and read on
file_io_threadsthreads (4 by default, shared by all workers; a change takes a restart), with at most one 32 KiB read out per response, paced by the client. Where the kernel can tell the data is cached, the worker skips the round trip: lookups on Linux (openat2withRESOLVE_CACHED), reads everywhere (RWF_NOWAIT, ormincoreon filesystems without it, such as overlayfs). When 1024 new lookups are already waiting for a thread, further requests get 503. -
Each worker keeps what its lookups found, like nginx's
open_file_cachewithopen_file_cache_errors on: open descriptors with their size, mtime and inode, and paths that don't exist or are directories.try_filesentries and precompressed variants are cached the same way. On by default:.open_file_cache = .{ .max = 1000, .valid_ms = 1000, .inactive_ms = 60_000 },
An entry is used for
valid_ms; after that the path is opened and stat-ed again, and a file replaced (renamed over), created or deleted shows.maxcounts entries per worker (0 turns the cache off) and is lowered at start, with a log line, so that all workers' cached descriptors stay within a quarter ofRLIMIT_NOFILE. Entries unused forinactive_msare closed. A reload starts with empty caches. Withinvalid_ms, a file edited in place is served with its old length and ETag: truncated, its response is cut short (the connection closes); grown, the extra bytes are left out. -
Over plain HTTP/1.1, a static body over 32 KiB that isn't compressed on the fly goes out with
sendfile, range by range as the socket takes it, for the parts the page cache holds; the rest is read on an I/O thread as over TLS and HTTP/3 (sendfile reads the file on the calling thread, so a cold page would stall the worker). Where the filesystem can't sendfile, the socket falls back to reading the cached range itself. -
Servers sharing a listen address are virtual hosts, chosen by
Host(exact name, then one-label wildcard, then the first server). -
TLS keys may be EC P-256, Ed25519 or RSA (2048 to 4096 bits). TLS 1.2 is not supported. A key that doesn't belong to the first certificate of its
certfile fails the load. -
tcp_proxiesforwards a whole TCP connection to an upstream, parsing nothing and terminating no TLS: for a database, an SSH or a game server behind the same balancing, passive failure tracking and health checks as the HTTP proxy. The peer is chosen when the client connects (ip_hashsees the client address), and the tunnel closes afteridle_timeout_ms(10 minutes) with no traffic either way, or as soon as either side closes and the other has drained. Tunnels count againstlimits.max_connectionslike any connection, and a reload hands the listening socket over as it does for HTTP. A port servingserverscan't also be atcp_proxy. On Linux a direction that keeps filling the read buffer is handed tosplice(2), which moves the bytes through a pipe without copying them into the process; there is nothing to configure.
.locations = .{
.{ .prefix = "/", .root = "/var/www" },
.{ .exact = "/health", .@"return" = .{ .body = "ok\n" } }, // nginx `=`
.{ .prefix = "/static/", .no_regex = true, .root = "/var/www" }, // nginx `^~`
.{ .regex = "\\.(png|jpe?g|gif)$", .case_insensitive = true, // nginx `~*`
.root = "/var/www", .add_headers = .{.{ .name = "cache-control", .value = "max-age=86400" }} },
.{ .regex = "^/users/(\\d+)/avatar$", .proxy_pass = "backend/avatars/$1" }, // nginx `~`
},A location matches the path after percent-decoding and resolving dot segments, in nginx's order:
- An
exactlocation equal to the path wins. - Otherwise the longest matching
prefixis remembered; if it hasno_regex, it wins. - Otherwise the
regexlocations are tried in config order, and the first that finds a match wins. - Otherwise the remembered prefix wins, or the request gets a 404.
A regex matches anywhere in the path unless anchored with ^/$. Its
groups are $1..$9 ($0 the whole match) in return.location,
add_headers, proxy_set_headers and proxy_pass URIs, percent-encoded
like $uri. Case-insensitive matching folds ASCII letters only.
Regular expressions are a PCRE subset, matched without backtracking in time
proportional to pattern size × path length: literals, ., classes
([a-z_], [^/], \d \w \s \D \W \S), anchors ^ $, word boundaries
\b \B, groups ( ) and (?: ), |, greedy and lazy * + ? {n} {n,} {n,m}, and the escapes \t \n \r \f \v \xHH and \.-style punctuation.
Backreferences, lookahead and lookbehind, named groups, inline flags such as
(?i), atomic groups and possessive quantifiers fail the config check with
the reason and offset. A pattern is at most 1024 bytes and compiles to at
most 1000 instructions (so x{1,1000} fits only for a small x); only the
first nine groups are captured.
.{
.listen = .{.{ .port = 80 }},
// Before a location is chosen.
.rewrite = .{.{ .regex = "^/blog/(\\d+)/(.*)$", .replacement = "/posts/$2?year=$1", .flag = .last }},
.locations = .{
.{ .prefix = "/posts/", .proxy_pass = "backend" },
.{ .prefix = "/old/", .@"return" = .{ .status = 404 }, .rewrite = .{
.{ .regex = "^/old/docs/(.*)$", .replacement = "https://docs.example.com/$1", .flag = .permanent },
.{ .regex = "^/old/(.*)$", .replacement = "/$1", .flag = .last },
} },
},
}Each rule whose regex finds a match in the path replaces the URI with
replacement, in which the rule's groups are $1..$9 and other
variables work too. The flag says what happens next, as in nginx:
- none: go on with the next rule; if the URI changed, the locations are matched again after the last one.
.last: stop, and match the locations again with the new URI..@"break": stop, and carry on in the current location with the new URI..redirect/.permanent: answer 302 / 301 with the new URI asLocation. A replacement starting withhttp://,https://or$schemeredirects (302) whatever the flag.
Server rules run once, before a location is chosen (last and break
both just end them); a location's run when it is chosen, before access
checks, limits and its handler. The query string is appended to the new
URI, after a & if the replacement has a query of its own; a replacement
ending in ? drops it. $request_uri stays what the client sent, while
$uri, $args and the path a proxied request or file lookup uses are the
rewritten ones. A proxied request sends the rewritten path in full,
ignoring the proxy_pass URI unless that has variables, as nginx does. A
request whose URI changes more than 10 times gets a 500. Rewrites don't
apply to WebTransport CONNECTs.
.{ .prefix = "/assets/", .root = "/var/www", .precompressed = .{ .br, .zstd, .gzip }, .gzip = true },precompressed serves app.js.br, app.js.zst or app.js.gz for a
request for app.js, when that file exists as a regular file beside it and
the client's Accept-Encoding takes the coding. Among the codings it
takes, the highest q-value wins and equal ones go by the list's order. A
coding below an explicit identity q-value is passed over, * stands for
codings the header doesn't name, and an entry with a malformed q-value is
ignored. With no acceptable variant the original is served, even to a
client that sent identity;q=0.
The variant is its own representation: Content-Encoding names the
coding, Content-Type comes from the original's name, and Content-Length,
Last-Modified and the ETag come from the compressed file, whose ETag also
ends in the coding ("…-br") so it never matches the original's. Range
requests count bytes of the compressed file, as in nginx; If-None-Match,
If-Modified-Since, If-Range and HEAD work on it as on any file.
Precompress at build time, for example brotli -k -q 11 app.js,
zstd -k -19 app.js, gzip -k -9 app.js, and keep each variant in step
with its original: routez doesn't compare them.
A variant counts as the file, so it's served even when the original is
missing; a client that doesn't take it gets a 404. With try_files, an
entry matches if its file exists or has a variant the client takes, so for
such a client a lone .gz stops the fallback.
gzip = true compresses text-like responses (text/*, JSON, JavaScript,
XML, SVG, wasm) not known to be under 1 KiB, for clients that take gzip, up
to 64 at once per worker; beyond that, responses go uncompressed. It leaves
alone responses that already have a Content-Encoding (such as a
precompressed file or an upstream's), 206 partial responses, and responses
marked Cache-Control: no-transform. The compressed response gets a weak
ETag and no Content-Length; a HEAD request gets the same headers.
Every response that could be encoded differently for another client,
compressed or not, carries Vary: Accept-Encoding (a static file's 304s
too), so caches keep the variants apart. In a precompressed location
that is any response for a path with a variant beside it, taken or not and
whatever the type, including the 404 for a path whose only file is a
variant the client doesn't take.
.{
.limits = .{ .max_connections_per_ip = 100 },
.servers = .{.{ .listen = ..., .locations = .{
.{ .prefix = "/login", .proxy_pass = "backend", .limit_req = .{ .rate = 2, .burst = 5 } },
.{ .prefix = "/api/", .proxy_pass = "backend", .limit_req = .{ .zone = "api", .rate = 50, .burst = 100 } },
.{ .prefix = "/v2/", .proxy_pass = "backend", .limit_req = .{ .zone = "api", .rate = 50 } },
} }},
}limit_reqallows each client addressraterequests per second, andburstmore at once; the rest get 429 withRetry-After: 1. It applies to HTTP/1.1, HTTPS and HTTP/3 alike. Locations naming the samezoneshare each client's bucket (they must agree onrate; each has its ownburst); a location without one has its own.limits.max_connections_per_ipcaps the TCP connections (HTTP and TLS) one address holds; more are closed at accept. Areal_ip_fromproxy isn't counted, and on aproxy_protocollistener the client its header names is (see Trusted proxies).- Both count across all workers, and across a reload: buckets carry over (a zone by name, an unnamed one by server name, listen address and prefix), and connections accepted by the old workers count until they close.
- The counts live in a table of
limits.max_tracked_clientsentries (100 000 by default, about 32 bytes each): one per address holding connections, one per address and zone being limited. Entries are dropped once a bucket has refilled or the last connection closes. When the table is full of live entries, new clients go unlimited, with a warning at most once a minute and theroutez_limit_table_untracked_totalcounter, rather than being refused: refusing would let anyone with enough addresses lock out every new client, and those addresses already let them sidestep per-address limits. limits.max_connections(10 000) counts per worker, so a four-worker server holds up to 40 000 TCP connections; connections over the cap are closed right after accept, counted byroutez_connections_refused_max_connections_total, and logged once per worker. Long-lived connections (WebSocket tunnels, SSE) make it the limit that bites first: raise it for them. It is lowered at startup to fitRLIMIT_NOFILE, which every worker's clients and their upstream connections share, with a warning saying so.
.{ .servers = .{.{
.listen = .{ .{ .port = 443, .tls = true, .quic = true } },
.tls = .{ .cert = "fullchain.pem", .key = "privkey.pem",
.client_ca = "clients-ca.pem", .client_verify = .optional },
// Every location without rules of its own.
.access = .{ .{ .allow = "10.0.0.0/8" }, .{ .allow = "2001:db8::/32" }, .{ .deny = "all" } },
.locations = .{
.{ .prefix = "/", .root = "/var/www" },
.{ .prefix = "/public/", .root = "/var/www", .access = .{.{ .allow = "all" }} },
.{ .prefix = "/admin/", .proxy_pass = "backend",
.auth_basic = .{ .realm = "Admin", .user_file = "/etc/routez/htpasswd" },
.proxy_set_headers = .{.{ .name = "x-user", .value = "$remote_user" }} },
.{ .prefix = "/internal/", .proxy_pass = "backend", .require_client_cert = true,
.proxy_set_headers = .{
.{ .name = "x-client-dn", .value = "$ssl_client_s_dn" },
.{ .name = "x-client-verify", .value = "$ssl_client_verify" },
} },
},
}} }Checks run in this order, after limit_req: IP rules (403), client
certificate (421, 403), Basic auth (401). They apply to HTTP/1.1, HTTPS,
HTTP/3 and WebTransport CONNECTs alike.
- IP rules:
.allow/.denywithall, an address or a CIDR network, IPv4 or IPv6; the first rule matching the client decides, and a client none matches is allowed (as in nginx). A location'saccessreplaces its server's. IPv4 rules also match IPv4-mapped IPv6 peers. The client is the TCP or QUIC peer, or the one a trusted proxy names (see Trusted proxies). - Basic auth:
auth_basicanswers 401 withWWW-Authenticate: Basic realm="..."until the credentials match the htpasswd file. Entries may be bcrypt (htpasswd -B; cost 4 to 16) or{SHA}(htpasswd -s; unsalted SHA-1, accepted with a warning). apr1 MD5, crypt(3) and plain-text entries are refused at load with the line named, as is a missing file;-tchecks the files, and a reload rereads them. TheAuthorizationheader is passed upstream, as nginx does; remove it with.proxy_set_headers = .{.{ .name = "authorization", .value = "" }}.$remote_userholds the user, for headers and the access log (combinedlogs it). bcrypt costs tens to hundreds of milliseconds, so checks run on a few verifier threads, never on a worker. A correct password is cached for five minutes (an HMAC of the stored hash, user and password; changing the password invalidates it), so a browser sending it with every request costs one check. Each client may start 5 uncached checks per second (burst 10, across workers) before getting 429, and at most 128 wait for a thread before requests get 503. An unknown user is checked against another entry's hash, so it takes as long as a wrong password. - Client certificates:
tls.client_camakes the server ask TLS and QUIC clients reaching it by SNI for a certificate, verified against that CA bundle (chain, validity, and the clientAuth usage when present). Withclient_verify = .required(the default) a client without a valid one fails the handshake (certificate_required,unknown_ca, ...); with.optionalit gets in,require_client_cert = trueon a location answers 403, and the variables sayNONE. A certificate that fails to verify fails the handshake either way. Several servers can share a port, each with its own CA or none; a request whoseHostnames a server with aclient_caother than the one its handshake ran under (SNI one name, Host another) gets 421. Session tickets are neither issued nor accepted for such a server, so a resumed session can never skip the certificate.client_verifymeans nothing on a plain-HTTP listener: don't serve the same locations there, or mark themrequire_client_cert.
Behind a load balancer every client is the balancer, unless routez is told which proxies to believe:
.{
.real_ip_from = .{ "10.0.0.0/8", "2001:db8::/32" },
.real_ip_header = "x-forwarded-for", // the default; null for the PROXY protocol alone
.real_ip_recursive = true, // for a chain of proxies
.servers = .{.{
.listen = .{
.{ .port = 80 },
// Behind an L4 balancer: HAProxy's send-proxy, AWS NLB, ...
.{ .port = 443, .tls = true, .proxy_protocol = true },
},
...
}},
}- Header: a request from a
real_ip_frompeer takes its client fromreal_ip_header, a comma-separated list of addresses (ports allowed, several header lines read as one list). Withoutreal_ip_recursivethe rightmost address is the client; with it, the rightmost that isn't itself inreal_ip_from(the leftmost if they all are), soX-Forwarded-For: client, proxy1fromproxy2namesclientwhenproxy1is trusted. From any other peer the header means nothing. An address in the walk that doesn't parse leaves the peer as the client, as in nginx. - PROXY protocol: on a TCP listener with
proxy_protocol = true, every connection opens with a v1 or v2 header, ahead of TLS, and the client it names is the connection's for its lifetime. Onlyreal_ip_frompeers may connect; others are closed at accept. So is a connection whose header is malformed (a v1 line over 107 bytes, a v2 header over 4 KiB, anything the spec doesn't allow) or hasn't arrived withinlimits.header_timeout_ms;routez_connections_refused_proxy_protocol_totalcounts them.LOCALconnections (the balancer's health checks),UNKNOWNand UNIX-socket sources keep the peer's address; TLVs are skipped. If the client the header names is itself inreal_ip_from, itsX-Forwarded-Forcounts. - Where it applies: IP rules,
limit_req,auth_basic's per-client rate limit,ip_hash,$remote_addr, access logs and theX-Real-IPsent upstream all see the real client, over HTTP/1.1, HTTPS, HTTP/3 and WebTransport CONNECTs.$realip_remote_addris the TCP or QUIC peer.X-Forwarded-Forupstream gets the address the request came from appended (the peer, or the PROXY protocol's client), so the list stays one entry per hop. max_connections_per_ipis decided at accept, before any header: areal_ip_frompeer isn't counted, and on aproxy_protocollistener the client the header names is counted instead. Clients a proxy names in a header are bound bylimit_reqandmax_connectionsonly.- These settings are global, not per server: the PROXY header is read
before SNI or
Hosthas chosen a server.
.{
.user = "www-data", // after binding; group defaults to its own
.error_log = "/var/log/routez/error.log",
.log_level = .warn, // .err, .warn, .info (default), .debug
.access_log_path = "/var/log/routez/access.log",
.access_log_format = "json", // "main" (default), "combined", "json" or a template
// .access_log_format = "$remote_addr [$time_iso8601] \"$request\" $status $request_time",
.servers = ...,
}- Started as root with
userset, routez binds its listeners, opens its log files, hands ACME storage to that user, then gives up root in every thread before serving. If that fails it exits rather than serve as root. After that a reload takes the listening sockets (TCP, QUIC and UDP) over from the running workers, so it keeps ports below 1024, but it can't bind a new one: such a reload is refused, naming the port, and the old configuration keeps running. The config file, certificates and document roots must be readable byuser, since reloads read them as that user. - Without
access_log_pathanderror_log, both go to stderr. SIGUSR1 reopens both by path, after logrotate has moved them; lines being written meanwhile go to one file or the other, none is lost. The new files are created byuser, so the log directory must be writable by it, or the rotation must create them (logrotate'screate); a file that can't be reopened keeps being written where it was. - Access log templates take the variables above and
$status,$body_bytes_sent,$request_time,$request,$request_method,$protocol,$upstream_addr,$request_completion,$time_iso8601,$time_local,$msecand any request header as$http_<name>($http_user_agent); seesrc/access_log.zig. Over HTTP/1.1, as in nginx, a request is logged once its response has been handed to the kernel, and$body_bytes_sentcounts only what was: a client that drops the connection before then is logged with the bytes it got and no$request_completion. Values are escaped as nginx does (\xHH), or for JSON with.access_log_escape = .json; thejsonpreset always is. Times are in UTC.main, the default, isclient "GET /path HTTP/1.1" status bytes time host= upstream=. .metrics = trueon a location serves the Prometheus text format: connections accepted and open (TCP and QUIC), requests by protocol, responses by protocol and status class, body bytes in and out, per upstream server requests, failures and health-check state, reloads, QUIC datagrams steered between workers, workers, start time and version, connections and requests refused by the client limits and the occupancy of their table, and connections refused on PROXY protocol listeners. Counters are process-wide and survive reloads. Restrict it like any location, for instance on a listener bound to a private address.
return takes a location for redirects, and it, add_headers and
proxy_set_headers values and proxy_pass URIs may use variables, written
$name or ${name}:
// Plain HTTP to HTTPS.
.{ .listen = .{.{ .port = 80 }}, .locations = .{
.{ .prefix = "/", .@"return" = .{ .status = 301, .location = "https://$host$request_uri" } },
} },| variable | value |
|---|---|
$scheme |
http or https |
$host |
Host / :authority lowercased, without the port; the first server_names entry if absent |
$request_uri |
path and query as the client sent them |
$uri |
normalized path (dot segments resolved), percent-encoded; after a rewrite, the new one |
$args, $is_args |
query string without the ?; ? if there is one |
$remote_addr |
client IP: the one a trusted proxy names, else the TCP or QUIC peer |
$realip_remote_addr |
the TCP or QUIC peer, whoever it names |
$remote_user |
the user auth_basic let in |
$ssl_client_verify |
SUCCESS for a verified client certificate, else NONE |
$ssl_client_s_dn, $ssl_client_i_dn |
its subject and issuer, RFC 4514 (CN=alice,O=Example) |
$ssl_client_serial |
its serial number, hex |
$ssl_client_fingerprint |
SHA-1 of the certificate, hex |
$1..$9, $0 |
groups of the last regex that matched (a rewrite's or the location's), and its whole match, percent-encoded |
Pass certificate details upstream with proxy_set_headers: it replaces
any header the client sent under the same name (and an empty value removes
it), so a client can't forge them.
An unknown variable, or a $ not starting one, fails the config check. A
value that would expand to something invalid in a header gets a 400.
Instead of cert and key, a server can ask an ACME CA for its certificate:
.{
.listen = .{ .{ .port = 80 }, .{ .port = 443, .tls = true, .quic = true } },
.server_names = .{ "example.com", "www.example.com" },
.tls = .{ .acme = .{
.email = "ops@example.com",
// Default: Let's Encrypt production. Try staging first:
// .directory = "https://acme-staging-v02.api.letsencrypt.org/directory",
.storage = "/var/lib/routez/acme",
} },
.locations = .{ .{ .prefix = "/", .root = "/var/www" } },
}| field | default | |
|---|---|---|
email |
none | contact for the CA's expiry and policy notices |
directory |
Let's Encrypt production | the CA's directory URL (https) |
storage |
/var/lib/routez/acme |
account key and certificates |
ca_file |
system roots | PEM bundle to trust for the directory's HTTPS (test CAs such as Pebble) |
renew_days |
30 | renew when fewer days than this remain (or half the lifetime, if that is shorter) |
check_interval_s |
43200 | how often stored certificates are checked |
order_timeout_s |
300 | longest one attempt may take, network waits included |
- The certificate covers every name in
server_names. Configuringacmemeans agreeing to the CA's terms of service. - Validation is HTTP-01: while an order is pending, every plain-HTTP listener
answers
/.well-known/acme-challenge/<token>ahead of its locations. The CA connects to port 80 of each name, so one must reach a plain listener (routez warns when none listens on 80). - One thread per process talks to the CA, off the worker event loops.
- At startup the stored certificate is used as long as it hasn't expired. When the names changed, the stored one covering the most of them keeps serving until the new one arrives. With nothing usable stored, the TLS listeners come up at once with a self-signed placeholder (clients see a certificate error, not a refused connection) while one is obtained.
- A new certificate is written to storage, then the running configuration is reloaded as for SIGHUP: new workers load it, old ones drain. That reload reuses the configuration text already running, so edits to the file still wait for a SIGHUP. A reload that fails is asked for again every 10 s.
- Failures are logged and retried after 1 minute, doubling to 32 minutes, or
later if the CA says so (Retry-After, rate limits). An attempt that hits
order_timeout_sis abandoned. A certificate issued but not yet stored, or an order already finalized, is picked up on the retry rather than ordered again, and no order is placed while storage can't be written. - Storage layout, directories 0700 and files 0600, replaced atomically:
<storage>/<ca-host>/account.keyand<storage>/<ca-host>/<name>.pem(the chain, then its key), where<name>is the alphabetically first server name. Keeping them per CA host means staging certificates are never served oncedirectorypoints at production.
zig build test # unit tests
tests/e2e/run.sh # end-to-end over HTTP/1.1, TLS, HTTP/3 and WebTransport
tests/acme/run.sh # ACME against Pebble in Docker (skipped without Docker)
zig build regex-diff && tests/regex/differential.py # regex engine against Python's reThe regex differential test compares every group's span on a few thousand
random patterns and inputs; where Python's re differs from PCRE (around
repeated groups that can match empty) it asks PCRE2's pcre2test, if
installed. zig build fuzz runs the fuzz targets, regex compilation and
matching among them.
The end-to-end script needs python3, bun, node >= 22, curl, and a built
../quic-zig (its WebTransport echo server is the relay's upstream). The
HTTP/3 checks need a curl built with HTTP/3: Homebrew's on macOS; on Linux,
without one, the script fetches a pinned static build (stunnel/static-curl,
checksummed) into ~/.cache/routez-e2e. Set CURL_BIN to use another.
It builds routez with -Dfault-injection, a test-only option under which
files named *slow-read* read as if from a stalled disk, and requests naming
small-sndbuf get a 16 KiB socket send buffer. The ACME script needs
Docker, python3, curl and openssl; it runs Pebble, Let's Encrypt's test CA, with real HTTP-01
validation against routez.
bench/run.sh # HTTP rows with wrk
bench/run.sh rate # latency at a rate every server is held to
bench/run.sh h3 # HTTP/3 with h2load
bench/run.sh l4 # the layer-4 TCP and UDP proxies
bench/run.sh hostile # storms, stalled clients, full handshakes, reload
bench/run.sh soak # a long mixed run, watching memory
bench/run.sh ws # concurrent WebSocket tunnels
bench/sweep.sh WORKERS 1 2 4 # one suite over the values of one knob
bench/scorecard.py # where routez is behind, worst first
bench/profile.sh http fileset routez # what one losing row spends itself on
bench/history.py compare <run> # what moved since the last revisionEach suite builds routez, starts all three servers beside a shared upstream and
measures one at a time, rotating the order between rounds. Nothing is measured
until a gate has checked that every server answers the row correctly, byte for
byte, and negotiates the same TLS parameters: a row that compares different work
is worse than no row. Results land in bench/results/[<suite>-]<timestamp>/.
bench/scorecard.py reads the newest run of each suite and ranks routez against
the better of nginx and HAProxy on every figure, worst first, so a pass ends in a
list of things to fix rather than a table routez wins. A figure whose spread
across rounds overlaps the rival's counts as a tie, not a difference; several of
them move enough between rounds that a few percent means nothing.
To see what a change to routez or quic-zig did to HTTP/3, measure both builds in
one run: ROUTEZ_B=<tree> and/or QUIC_ZIG_B=<tree> with bench/run.sh h3
builds a second routez and measures it as routez-b next to the first, round
for round. The report pairs each round's two figures and gives the median
change, its range and a sign test. Two separate runs of one build on a busy Mac
have differed by 90%; paired, one build against itself stays within ±2.2% on
every row but the h3-conns ones, whose two regimes no pairing can rank.
The HTTP rows live in bench/workloads.txt, one per line, and WORKLOADS= picks
a subset. Knobs: WORKERS (3), CONNS (256), DURATION (10 s), ROUNDS (3),
ACCESS_LOG (off). A row needing settings that would change every other row's
result names a profile and gets its own server processes.
Docker Desktop on an Apple M-series Mac (10 cores), 20 Sep 2026: routez 53d2011, nginx 1.30.5 and HAProxy 3.2.23 on OpenSSL 3.5, 3 workers each, with the server, wrk and upstream on separate cores. Median of 3 × 10 s runs, keep-alive, in requests per second. Relative numbers only; a VM is not a benchmark machine.
| Workload | nginx | HAProxy | routez |
|---|---|---|---|
Fixed response (return) |
529k | 320k | 627k |
| 10 KB static file | 312k | — | 308k |
| Reverse proxy to a keep-alive upstream | 208k | 155k | 262k |
| TLS: fixed response | 324k | 227k | 582k |
| TLS: 10 KB static file | 152k | — | 208k |
| TLS: new connection per request | 13k | 11k | 17k |
- HAProxy isn't a file server.
- TLS is 1.3 with AES-128-GCM and X25519 everywhere, routez's own choice; nginx and HAProxy are pinned to it.
- nginx gets
open_file_cache, which it has off by default. Without it it reopened every file on every request and served 217k on the 10 KB row, which is not a comparison worth winning. - The last row measures resumed handshakes: wrk reuses the session on each new connection, and wrk is at its own limit there, so read it as an ordering. Full handshakes are a hostile-suite row, driven by a client that offers no session.
- These rows are closed-loop, so their latency is the reciprocal of throughput
and says nothing about latency at a given load. That is what
run.sh rateis for: it holds every server to the same offered rate. At 208k requests a second on a fixed response, p99 is 3.6 ms for routez, 3.2 ms for HAProxy and 21.2 ms for nginx; on a 10 KB file at 172k it is 2.8 ms against nginx's 6.8.
From a full pass of every suite, worst first. Each is a place to look, not a verdict: the figure is routez against whichever of nginx and HAProxy does best.
| Row | routez | best other | |
|---|---|---|---|
| Full TLS handshakes, RSA 2048 | 1.0k/s | 3.6k/s | nginx |
| HTTP/3, 1 MB static file | 1.0k/s | 2.9k/s | nginx |
| gzip on the fly through the proxy | 5.5k/s | 15k/s | HAProxy |
| HTTP/3, 10 KB static file | 94k/s | 247k/s | nginx |
| p99 at a held rate, proxied | 4.30 ms | 1.79 ms | HAProxy |
| HTTP/3, 64 connections, 1 stream each | 36k/s | 83k/s | HAProxy |
| p99 at a held rate, 10 KB static file | 5.44 ms | 2.38 ms | nginx |
| Throughput kept under a connection storm | 27% | 56% | HAProxy |
| HTTP/3 reverse proxy | 105k/s | 203k/s | HAProxy |
| Reverse proxy, 1 MB response | 4.7k/s | 7.5k/s | HAProxy |
| gzip on the fly, 100 KB of text | 6.7k/s | 10k/s | nginx |
| HTTP/3, 10 streams per connection | 545k/s | 762k/s | nginx |
| Layer 4, a new TCP connection per request | 62k/s | 85k/s | nginx |
| A new connection per request | 187k/s | 231k/s | nginx |
| Full TLS handshakes, ECDSA P-256 | 7.1k/s | 8.8k/s | HAProxy |
| Memory per parked keep-alive connection | 0.7 KB | 0.2 KB | nginx |
| A reload every 2 s under load | 74 failed | 0 failed | HAProxy |
Every loss in the held-rate suite is a tail, never a median. routez has the best or near-best p50 on almost every row there and two to two and a half times the p99 on seven of them. wrk's closed-loop rows cannot show this, because their percentiles are the reciprocal of throughput; offering all three servers the same rate can.
It is not routez's tail, though — it is everyone's but HAProxy's. Sweeping the proxied row over 8, 64 and 256 connections puts routez's p99 at 1.92, 4.32 and 5.24 ms against nginx's 2.18, 4.48 and 5.22: routez is the best of the three at 8 connections and tracks nginx within noise above it, while HAProxy holds 2.22, 1.90 and 2.45. HAProxy keeps a flat tail as concurrency rises and the other two do not, and it is the same server that keeps 56% of its throughput under a connection storm where routez keeps 27%. Whatever HAProxy's scheduler does about fairness is the thing worth understanding here; there is no routez-specific anomaly to chase.
A profile of the connection-storm row is 12% in el0_svc, the syscall entry
path, and 4% reading the clock, with no allocation anywhere near the top. So that
row is about how many syscalls a connection costs, around eleven, and not about
the per-connection allocation it looked like. Two of them are known to be
avoidable, an fcntl for O_NONBLOCK and a getpeername, and libxev offers the
first through its accept flags: a first attempt at that did not take effect and
two tests caught the still-blocking socket, so it wants understanding rather than
another try.
Rows that have come off this list, and what did it:
| Row | was | now | |
|---|---|---|---|
| 10k files of 4 KB, one at random | 94k/s | 237k/s | 13% ahead of nginx |
| 10 KB static file | 303k/s | 343k/s | 10% ahead |
| 1 KB static file | 334k/s | 430k/s | 29% ahead |
| 100 KB static file | 157k/s | 195k/s | 9% ahead |
| 1 MB static file | 8% behind | 3% behind | |
| Memory per UDP flow | 2.3 KB | 1.9 KB | against nginx's 61.5 |
| gzip on the fly, 100 KB of text | 4.4k/s | 6.6k/s | 33% less CPU per request |
| gzip on the fly through the proxy | 4.0k/s | 5.4k/s | and all of it compressed |
| HTTP/3, 64 in flight over 16 connections | 133k/s | 273k/s | and far steadier |
The proxied gzip row also stopped flattering itself. A worker compressed at most 64 responses at once and sent the rest whole, which under that row's concurrency was 4.5% of them: 38 MB/s on the wire where 13 does, and a row that was not compressing everything nginx was. The cap is 256 now, for 7 MB of RSS.
Both static fixes are the same trade, and it is the one nginx makes: stop asking
whether the page cache holds the data and accept that being wrong costs one
worker a single disk read. Asking cost a thread handoff on small bodies and a
mincore per chunk on larger ones.
What is known about the top of that list:
- The two gzip rows are the codec, now that they are only the codec: a third
of the time was
std.hash.Crc32, which steps one byte at a time through a single table, so routez frames the gzip stream itself around raw deflate and a CRC32 that reads eight. Compressing still runs on the loop thread, one response at a time per worker, andstd.compress.flatemanages 146 MB/s at level 4 where zlib does 342. Moving it off the loop would fix the tail, not the rate; the rate means a faster deflate. - Handshakes are the asymmetric crypto, and for RSA they are almost nothing
else. quic-zig signs with its own Montgomery exponentiation now, which took RSA
from 0.7k to 1.0k a second. A 1024-bit exponentiation measures 437 µs against
std.crypto.ff's 1.52 ms, and the two a CRT signature needs account for 874 µs of the 1 ms a handshake takes: there is no bookkeeping left to remove, only the multiplication itself, which is Zig codegen against OpenSSL's hand-written aarch64 assembly. ECDSA, which routez prefers, is within 1.5x. - The file-set row is no longer CPU-bound: at 85% of its cores with the
client at 15%, it is waiting. Every cache miss hands a 4 KB read to four I/O
threads, and the handoff costs two futex round trips and an eventfd wakeup.
Reading small files inline, as nginx does unless
aiois on, is the change that would close it. - The layer-4 bulk row has left the list. A tunnel that has read four full
buffers in a row from one side hands that direction to
splice(2)through a pipe, so the bytes never enter the process; re-measured against both competitors, 5.0k 1 MB responses a second at 626 ms of CPU per thousand became 8.1k at 170 ms, against HAProxy's 8.5k at 178 on the same run. The tail goes with it: p99 was 70 ms while the row was throughput-bound, and is now 1.03 s, a queue at the higher rate rather than a slower response — HAProxy's is 1.06 s. Small exchanges never reach four full buffers and keep copying, which is the path that already wins the keep-alive row. - The reload row is down to the race itself: a request that arrives after the idle check and before the close. Reaching zero means HAProxy's model, never force-closing an established connection, which wants a bound on how many generations may coexist.
- The HTTP/3 rows paid a socket call per datagram until quic-zig's send batch
started using one. It collected up to 64 packets and then sent them one
sendmsgat a time, copying each one to do it; a run of same-sized packets to one address now leaves as a singlesendmsgcarryingUDP_SEGMENT, which is nginx'squic_gso, and the packer writes straight into the slot it will be sent from. That was worth 29% on both static rows and doubled the 16-connection row. What is left on the 1 MB row is 3.05 ms of CPU per response at 98% of its cores, spread with no peak to remove: AEAD 20%,memcpy12% copying the body into the stream,memset7%, the kernel's UDP path 9%, andSendStream.onAck7%. That last one is a symptom rather than a cause — the row loses 6700 datagrams to the kernel's receive buffers where nginx loses 50, and the retransmissions fragment the acknowledged-range set. Its RTT is a tenth of nginx's and its tail latency is ten times worse, so routez sends this row in bursts the receiver cannot take: pacing is the next thing to look at, not more per-packet trimming. Datagrams also stay at the 1200-byte floor with no path MTU discovery, worth about 1.2x.queueFlowControlUpdatesstill runs before every datagram for 3.6%, finding nothing to do on a download; skipping it exactly needs a consumed-bytes counter threaded out of the stream layer, and skipping it on a guess risks a stall. - Three HTTP/3 hypotheses are dead, each measured over three rounds against the same routez. Answering each batch of received datagrams before taking the next, so one connection's response does not queue behind every other connection's request, cost 24% on the 4-connection row and 12% on the 16-connection one and bought 4% where it was aimed. Raising the stream window 200-fold, in case a client doing one request per connection was waiting on MAX_STREAMS credit, changed nothing at all: 36k either way, p50 1.99 ms either way. Raising datagrams from the 1200-byte floor to 1452 is worth 10% on the 1 MB row and 5% on the 10 KB one, and costs 10% on the proxy row, so the size is worth having only where the path is known to carry it — which means discovering it, not assuming it.
- The row that runs one request at a time per connection is about per-request latency, not connection count. Sixty-four connections with ten streams each reach 545k; the same sixty-four with one stream each reach 36k, because each connection then spends 1.78 ms per request where nginx spends 1.08 ms and the network costs 164 µs. Most of that ~1 ms is h2load itself, so the server's own excess is a few hundred microseconds, and finding it wants a qlog trace of one connection rather than another guess.
HTTP/3 is about per-connection cost, not per-request cost. Holding 64 requests in flight and moving them from streams onto connections:
| Requests in flight | nginx | HAProxy | routez |
|---|---|---|---|
| 4 connections × 16 streams | 50k | 114k | 251k |
| 16 connections × 4 streams | 191k | 186k | 239k |
| 64 connections × 1 stream | 59k | 38k | 39k |
routez is the fastest of the three at four and sixteen connections and the slowest at 64. Over HTTP/1.1 with the same client it wins every row by 29 to 50%, so this is not QUIC being slow in routez; it is cost that grows with the number of connections rather than with the work.
Half of that cost was quic-zig calling onTimeout for every connection on every
pass before asking whether any deadline had passed. Guarding it took the 64-
connection row from 36.2 to 16.2 ms of CPU per thousand requests and from 30k to
39k a second. routez now spends less CPU per request there than nginx does, 16.2
against 19.3, and still serves fewer: what remains on that row is not CPU. Its
median request takes 2.0 ms against nginx's 0.3 ms over a 75 µs round trip, with
the server at 32% and the client at 12%.
A qlog trace splits that 2 ms in two. The server's own share is real and comes from the shape of the event loop: it empties the socket, processes every connection, then sends. A request therefore waits for everything already queued, and with 64 connections a median of 130 packets belonging to other connections passed between a request arriving and its answer leaving. Bounding how many datagrams one pass takes cuts that gap from 10.0 ms to 1.0 ms in the trace.
The rest is the measurement. Bounding the drain does not move what the client sees, and h2load reports about 2.6 ms for every server at one connection, so a row with one stream per connection is partly reporting its own client. That also means this row overstates the deficit, and settling the remainder wants a load generator that is not the limit. The bound is not committed: its effect on throughput and CPU here sits inside a 36k-54k spread, which is too noisy to tune a constant against.
Rows where routez was thought to be behind and is not, once the comparison was
fixed: memory per UDP flow reads 1.9 KB against nginx's 61.5 KB, after dropping
the proxy_responses 1 that had nginx retiring each session after one reply
instead of holding the flows.
bench/run.sh ws # concurrent WebSocket tunnels, one worker per serverEach server runs as a single process with one worker and proxies WebSocket
over HTTP/1.1 to a Node.js app built on ws (an echo server). The Node app
with no proxy in front is the baseline. The client opens the connections,
holds them idle, then sends a fixed total of echoes spread over all of them.
Results land in bench/results/ws-<timestamp>/. Knobs: LEVELS
(1000 10000 50000), RATE (10000 echoes/s in total), HOLD (5 s),
DURATION (10 s), ROUNDS (3), CLIENTS, SERVERS.
Same machine, 20 Sep 2026: Node.js 22.22 and ws 8.21; the server, the app
and the client are pinned to separate cores. One round per level.
Memory per open connection (lower is better). This is the server's RSS growth over its idle baseline, divided by the number of connections. It barely moves between rounds.
| Open connections | nginx | HAProxy | routez |
|---|---|---|---|
| 1,000 | 16.8 KB | 11.3 KB | 6.4 KB |
| 10,000 | 18.0 KB | 5.3 KB | 5.3 KB |
| 50,000 | 17.8 KB | 4.8 KB | 5.3 KB |
Echo latency, p99 (lower is better). 10,000 messages per second in total, spread over all open connections. "No proxy" is the client talking to the app directly.
| Open connections | No proxy | nginx | HAProxy | routez |
|---|---|---|---|---|
| 1,000 | 5 ms | 5 ms | 7 ms | 5 ms |
| 10,000 | 10 ms | 17 ms | 26 ms | 12 ms |
| 50,000 | 89 ms | 53 ms | 75 ms | 68 ms |
- The p99 is noisy from round to round, above all at 50k connections. The p50 stayed under 1.6 ms everywhere.
- On Linux, sockets share one read buffer per worker instead of holding 16 KiB each; without that, routez held 21 KB per connection here.
- The 1,000-connection row includes each server's fixed startup cost, which is why it is higher for everyone.
- One Node process can't absorb much more. At 1 message per second per connection it saturates near 25k connections, so the load is a fixed total rather than a rate per connection.
-
ACME: HTTP-01 only, so no wildcard names (they need DNS-01) and port 80 must be reachable from the internet. No certificate revocation, ARI or external account binding. Every change of certificate reloads all workers.
-
Let's Encrypt rate-limits issuance (for example 5 certificates per exact set of names per week, and failed validations per hour); test against the staging directory before production.
-
HTTP/3 requests are logged when their response is produced, not when the client has acknowledged it: a stream reset or lost connection after that isn't reflected in
$body_bytes_sentor$request_completion. -
During a reload, new QUIC connections that the kernel hands to the old generation's sockets are refused until it finishes draining (up to 10 s); browsers fall back to TCP meanwhile.
-
A reload that lowers
workerscloses the extra TCP listening sockets, resetting any connection queued on them at that moment. -
QUIC connections close after
limits.quic_idle_timeout_msof silence (30 s by default). Each end uses the smaller of the two advertised values, so raising it only helps clients that advertise more; a client that vanishes holds its connection slot until the timeout. -
With several workers, a stateless reset from a client that changed address can reach a worker that doesn't own its connection and is dropped: its connection ID is random by design, so QUIC-LB can't steer it. The connection then closes at the idle timeout instead of at once.
-
max_connections_per_ipcounts TCP connections only. A QUIC connection's address isn't validated when it is accepted (a spoofed Initial would count against someone else's) and changes when the client migrates;max_connectionsandlimit_reqbound QUIC clients instead. -
A disk that stalls for good ties up an I/O thread per request reading from it; once all are taken, other static requests wait too (proxied ones don't). On macOS a lookup the open-file cache can't answer (a path's first request, or its first after
valid_ms) takes a round trip to an I/O thread: nothing there can tell that an open won't wait. -
sendfileis used for plain HTTP/1.1 only: TLS and HTTP/3 encrypt in userspace, and bodies compressed on the fly are made there. -
Compression on the fly is gzip only: Zig's standard library has no Brotli or zstd encoder, and a small one written here would compress worse than gzip. Precompress with
brotliorzstdat build time and serve the files withprecompressedinstead. -
Upstream pools and health state are per worker, so health checks run once per worker per interval; the
routez_upstream_healthymetric is the verdict of whichever worker probed last. -
After dropping root, a reload can't add a listener on a port below 1024 or change
user, and SIGUSR1 can't reopen a log file whereusercan't write: nothing keeps root to do those, unlike nginx's master process. -
TLS to upstreams: TLS 1.3 only, no session resumption (pooled keep-alive connections avoid most handshakes) and no revocation checks.
-
Client certificates: no revocation checks (CRL, OCSP) and no post-handshake authentication, so a location can't ask for a certificate the handshake didn't; a server's
client_caapplies to its whole name. Connections to aclient_caserver are never resumed from a ticket. -
The PROXY protocol is read on TCP listeners only: not by QUIC on the same port or by the UDP proxy, and routez doesn't send it to upstreams.
-
A literal
proxy_passtarget is always plain HTTP; declare an upstream to use TLS. -
Regular expressions lack backreferences, lookaround, named groups, inline flags, Unicode classes and POSIX classes (
[[:alpha:]]); they match bytes, and case-insensitive matching is ASCII only. -
macOS does not spread TCP connections across SO_REUSEPORT listeners, so extra workers only help on Linux.