Clustering and the DMZ Webhook Relay¶
vornik runs as a single all-in-one daemon by default, and that is the right shape for almost every deployment. When you need to scale execution independently or isolate public webhook ingress in a DMZ, you can split vornik into specialised node roles behind a shared database and object store.
This guide walks through the role model and a complete setup of the most common multi-node pattern: a DMZ webhook node that forwards events over mutual-TLS to a job tier. For the high-level concept, see Cluster topology and node roles; for every config key, see the Configuration reference.
When to cluster¶
Split into roles for one of two reasons:
- Scale the worker tier. Run several
workernodes behind a shared PostgreSQL and object store so heavy task execution doesn't compete with UI/API traffic. Background singletons (schedulers, pruners) stay single-owner automatically via database leases. - Isolate webhook ingress. Put a
webhooknode in an untrusted network segment. The box exposed to the public internet holds no database handle and no model or broker credentials — it verifies each webhook signature and forwards the verified event inward over mutual-TLS.
Everything behind the public edge still shares one database and one object store. Clustering scales compute and shrinks the blast radius; it is not a multi-region or sharding feature.
Node profiles¶
A node's role is set with node.profile. Each profile is a preset over four
capabilities; you can override any one capability with node.serve_ui,
node.serve_api, node.serve_webhooks, or node.run_workers.
node.profile |
Serves UI | Serves API | Serves webhooks | Runs workers | Typical use |
|---|---|---|---|---|---|
all (default) |
yes | yes | yes | yes | single-node deployment |
ui |
yes | no | no | no | front-end tier |
worker |
no | yes | no | yes | job tier (scheduler + executor) |
webhook |
no | no | yes | no | DMZ-isolated webhook relay |
A node that serves webhooks but does not run workers is a relay node — that
is what enables (and requires) the node.relay block in the walkthrough below.
Reference topology¶
A three-tier cluster with a DMZ relay. The DMZ tier has no path to the database; its only route inward is the mutual-TLS relay.
flowchart LR
prov[Webhook providers<br/>GitHub / Slack / …]
subgraph dmz["DMZ network"]
wh["webhook node<br/>profile: webhook"]
end
subgraph trusted["Trusted network"]
ui["ui node(s)<br/>profile: ui"]
wk["worker node(s)<br/>profile: worker<br/>relay ingress listener"]
pg[(Database<br/>+ object store)]
end
users[Operators / browsers] --> ui
prov -->|"POST /api/v1/webhooks/…"| wh
wh -->|"verified event over mTLS"| wk
wh -.->|"by-proxy heartbeat over mTLS"| wk
ui --> pg
wk --> pg
The webhook node verifies the provider signature at the public edge, then forwards the verified payload over mutual-TLS to the job tier, which re-creates the task. Because the DMZ node holds no database, it reports its liveness by proxy over the same secured channel.
Walkthrough: set up a DMZ webhook relay¶
You configure two sides — the job tier (server side of the mTLS seam) and the DMZ webhook node (client side) — and they must trust the same CA.
1. Generate the mutual-TLS material¶
The relay is mutual-TLS: the job tier presents a server certificate, the webhook node presents a client certificate, and each verifies the other against a shared certificate authority. A small self-signed CA is sufficient — this trust chain is internal to your cluster and is never seen by webhook providers. Run these in bash (step 1b uses process substitution).
# 1a. A private CA for the relay.
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
-keyout relay-ca.key -out relay-ca.crt \
-subj "/CN=vornik-relay-ca"
# 1b. Job-tier SERVER cert. The subjectAltName must match the host the
# webhook node will use in node.relay.upstream (DNS name or IP:<addr>).
openssl req -newkey rsa:4096 -nodes \
-keyout jobtier.key -out jobtier.csr \
-subj "/CN=jobtier.internal"
openssl x509 -req -in jobtier.csr -CA relay-ca.crt -CAkey relay-ca.key \
-CAcreateserial -days 825 -out jobtier.crt \
-extfile <(printf "subjectAltName=DNS:jobtier.internal")
# 1c. DMZ webhook node CLIENT cert.
openssl req -newkey rsa:4096 -nodes \
-keyout dmz-webhook.key -out dmz-webhook.csr \
-subj "/CN=dmz-webhook-1"
openssl x509 -req -in dmz-webhook.csr -CA relay-ca.crt -CAkey relay-ca.key \
-CAcreateserial -days 825 -out dmz-webhook.crt
The subjectAltName in step 1b is required, not cosmetic: vornik matches
node.relay.upstream against the server certificate's SAN, so a SAN-less
certificate fails the handshake. The client certificate needs no SAN — it is
verified by CA chain, not hostname.
2. Configure the job tier (server side)¶
On each worker (or all) node that should receive relayed webhooks, add a
relay_ingress block. It binds a dedicated mutual-TLS listener, separate
from the public API port, and accepts only client certificates signed by your
relay CA.
# job-tier config
node:
profile: worker
relay_ingress:
addr: ":8443" # mTLS listener for relayed webhooks
server_cert: /etc/vornik/certs/jobtier.crt
server_key: /etc/vornik/certs/jobtier.key
client_ca: /etc/vornik/certs/relay-ca.crt # only certs it signed are accepted
All four relay_ingress keys are required together, and the block is only valid
on a node that runs workers — the daemon refuses to start otherwise.
3. Configure the DMZ webhook node (client side)¶
The webhook node is the public edge. Four things matter:
node.profile: webhookmakes it a relay node.node.relay.*points it at the job tier's mTLS listener. All four ofupstream,client_cert,client_key, andcaare required — the daemon will not start without them.database.driver: sqlite. A webhook node never writes webhook events to the database, but the daemon still opens a database backend at startup. Pointing it at a small local SQLite file keeps the DMZ box free of any database credential; leaving the default (postgres) would make a DMZ node with no route to the database fail to start.api.auth_enabled: false. API authentication is on by default, and when it is on the daemon requiresapi.api_keys— so a config without keys fails to start. A webhook node serves no bearer-authenticated API (only HMAC-verified webhook ingress), so turn auth off here rather than placing an API key on the public box. Keep your API keys on the UI/job tier, where the API is served.
# DMZ webhook node config
server:
address: ":8080" # public webhook ingress (front this with TLS)
node:
profile: webhook
relay:
upstream: https://jobtier.internal:8443 # the relay_ingress addr above
client_cert: /etc/vornik/certs/dmz-webhook.crt
client_key: /etc/vornik/certs/dmz-webhook.key
ca: /etc/vornik/certs/relay-ca.crt # CA that signed jobtier.crt
max_retries: 3 # optional; 0 → 3. Bounded retries before a 5xx to the provider
timeout: 5s # optional; empty → 5s per attempt
database:
driver: sqlite
path: /var/lib/vornik/relay.db # must be on a writable mount
api:
auth_enabled: false # no bearer-auth API on a webhook node; ingress is HMAC-verified
4. Run the webhook node¶
Use the thin container image for a webhook node — it carries no executor and
runs unprivileged, which is exactly what you want in a DMZ. Expose only the
webhook ingress port and put your TLS terminator in front of it; providers post
to POST /api/v1/webhooks/{…}.
podman run -d --name vornik-webhook-dmz \
-p 8080:8080 \
-v ./dmz-webhook-config.yaml:/etc/vornik/config.yaml:ro,Z \
-v ./certs:/etc/vornik/certs:ro,Z \
-v vornik-dmz-data:/var/lib/vornik:Z \
--health-cmd 'wget -qO- http://localhost:8080/livez || exit 1' \
--health-interval 30s \
vornik:thin
Do not expose the metrics port (9090) publicly, and do not mount a container
socket — a webhook node never spawns agent containers.
Validate and observe¶
Before starting (or restarting) any node, validate its configuration. The
cluster feature doctor confirms node.profile is a recognised preset and that
relay settings are coherent with the resolved role:
vornikctl doctor feature cluster
Once nodes are running, inspect the fleet from any node with API access:
vornikctl cluster status # live nodes, singleton ownership, version skew
vornikctl cluster status --json # raw response
The same data backs the cluster view in the admin UI. Database-backed nodes
report themselves on a timer; the DMZ webhook node reports by proxy over the
relay, and a stale node is aged out automatically. If a running webhook node is
absent from cluster status, the relay channel — not the node — is usually at
fault.
Operating notes¶
- Role and relay changes are restart-gated.
node.profile,node.relay.*, andnode.relay_ingress.*are read at startup; editing them takes effect on the next daemon start, not on a config reload. Run the feature doctor, then restart. - Restart order. Bring the job tier's relay listener up before the DMZ nodes
that forward to it. While the upstream is unreachable, a webhook node reports
not-ready on
/readyz(so a load balancer holds traffic off it) while staying alive on/livez. - Backpressure.
node.relay.max_retriesbounds retries before the webhook node returns a5xxto the provider, which then redelivers per its own policy. Keep retries small — provider redelivery, not in-process buffering, is the durability layer. - Certificate rotation. Rotating the relay CA invalidates every client certificate at once; issue new client certs and roll the DMZ nodes. Because this config is restart-gated, a cert swap is a restart, not a reload.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Won't start: "api.api_keys is required when api.auth_enabled is true" | API auth is on by default and a webhook node has no api_keys |
Set api.auth_enabled: false on the webhook node; keep API keys on the UI/job tier. |
| Webhook node won't start, fails on database connect | database.driver left at the postgres default with no route to the database |
Set database.driver: sqlite and a writable database.path. |
| Start-up error: "relay.{…} are all required in webhook relay mode" | Incomplete node.relay block on a webhook node |
Provide all four of upstream, client_cert, client_key, ca. |
| Start-up error: "node.relay is only valid for a webhook node" | relay.* keys on a node that isn't a relay node |
Set node.profile: webhook, or remove the relay keys. |
Relay returns 401 / TLS handshake failure |
Client cert not signed by the job tier's client_ca, or the server cert SAN doesn't match relay.upstream |
Re-issue certs from the shared CA; match the server SAN to the upstream host. |
Webhook node stuck not-ready (/readyz returns 503) |
Job tier relay listener unreachable from the DMZ | Confirm the relay_ingress listener is up and reachable; check the relay.upstream host and port. |
Webhook node missing from cluster status |
By-proxy heartbeat not reaching the job tier | Verify the relay channel (same checks as the handshake row); the heartbeat uses the same secured path. |