This config is the smallest thing that exercises every block you will touch in daily operation: an L4 relay, an HTTP reverse proxy with host routing, an experimental HTTP/3 listener, health checks, the admin scrape, and a JSON access log. Save it as config.json:
{
"listeners": [
{
"bind": "0.0.0.0:80",
"http": {
"routes": [
{ "host": "api.example.com", "prefix": "/", "cluster": "api" },
{ "prefix": "/", "cluster": "web" }
]
}
},
{
"bind": "0.0.0.0:443",
"l4": {
"routes": [
{ "sni": "db.example.com", "cluster": "postgres" },
{ "cluster": "fallback" }
]
}
}
],
"h3_listeners": [
{
"bind": "0.0.0.0:4443",
"routes": [
{ "host": "api.example.com", "prefix": "/", "cluster": "api" },
{ "prefix": "/", "cluster": "web" }
],
"tls": { "cert": "/etc/zgix/fullchain.pem", "key": "/etc/zgix/key.pem" }
}
],
"clusters": {
"web": {
"endpoints": ["127.0.0.1:9000"],
"check": { "type": "tcp", "fall": 3, "rise": 2 }
},
"api": {
"endpoints": ["127.0.0.1:9001", "127.0.0.1:9002"],
"pick": "p2c",
"check": { "type": "http", "path": "/healthz", "expect_status": 200 }
},
"postgres": { "endpoints": ["127.0.0.1:5432"] },
"fallback": { "endpoints": ["127.0.0.1:8443"] }
},
"admin": { "bind": "127.0.0.1:9100" },
"access_log": { "sink": "stdout" }
}
Reading it top to bottom:
- The
:80listener is anhttpreverse proxy. Its route table matcheshost, then aheaderpredicate if one is named, then the longestprefix—api.example.comgoes to theapicluster, everything else toweb. - The
:443listener is anl4relay that routes by TLS server name (SNI) without terminating TLS — the backend completes the handshake with the client and zgix never sees a plaintext byte. - The h3 listener terminates QUIC-TLS on UDP
:4443and proxies over the same clusters. QUIC has no plaintext mode, sotlsis required. - Every cluster names endpoints and, except the trivially local ones, a health check:
tcpproves the port accepts,httpproves the app answers. adminserves the Prometheus scrape plus a bounded access-log tail.access_logwrites one JSON line per exchange to stdout.
Exactly one protocol key (l4 or http) is required per TCP listener; bind and tls sit outside the protocol body because they apply to both.
Price it before you run it
zgix --check config.json
--check loads the config, opens every file it names, prices the pools and measures the fd demand against RLIMIT_NOFILE — everything a start does up to the banner — then exits without binding a port:
zgix 0.9.0
budgets:
memory total 47297 KiB = conn slots 1386 x 512 B + stream slots 1386 x 1552 B
+ relay buffers 1386 x 32808 B
+ ...
fds 4093 required (asserted against RLIMIT_NOFILE)
ring 4096 entries, completion queue 8192, in-flight ops <= 6869
config 2 listener(s), 4 cluster(s), 0 error page(s), access log on
rlimit RLIMIT_NOFILE 1024 soft, 524288 hard (a start raises the soft limit to the fd budget)
check config.json: valid, and this box can start it
The exit code is a signal you can branch a CI job on:
| exit | meaning |
|---|---|
0 | the config loads and this machine can start it |
1 | the config is wrong — unreadable, unparseable, semantically refused, or naming a file that will not load |
2 | the config is right and this machine cannot fit it: the fd budget is above the RLIMIT_NOFILE hard limit |
--check does the full load, so it needs the same file permissions the proxy would — a config naming production certificates cannot be checked on an agent that does not hold them. And an access_log file sink is created if it is absent, exactly as a start creates it.
Start it
zgix config.json
The startup banner and the exit tally go to stderr; stdout belongs to the access log. A valid config whose fd budget is above this machine’s RLIMIT_NOFILE hard limit is refused after the banner, with the numbers on screen next to the reason.
There is no config reload: the pools, the ring and every table are sized from the config at startup and never grow. To change the config, replace the process — see systemd for the supervised handoff.