GitHub

Edge nodes (managed HTTPS front)

iWhat this is — and is not

An edge node is a Linux box running nginx or Angie that Kapkan manages: it issues the zones' certificates on the box, generates the terminator's configuration, decides per request whether to let a client through — or to ask it to prove it is a browser first — and reports back. Kapkan never forwards a client's bytes — nginx terminates TLS and proxies to your origins; Kapkan tells it what to serve and, for each request, whether to serve it. You bring the network (anycast, transit, DNS) and the boxes; Kapkan brings the protection loop.

In plain terms: you list your websites (zones) in one file on the brain, point their DNS at your edge boxes, and each box gets a certificate, a generated nginx configuration and a local decision service that counts every client, refuses the ones that exceed the zone's policy and, where you allow it, asks a suspicious one to solve a small puzzle before it is blocked — without a round trip to anything.

# zones.yaml on the brain (edge.zones_file) — see the zones reference
zones:
  - name: shop.example.com
    origins: ["10.0.0.10:8080", "10.0.0.11:8080"]
    tls: { min_version: "1.2" }
    policy:
      mode: decide            # every request asks the local decision service
      failure_mode: open      # decider down → requests pass, undecided
      rate: { rps: 50, concurrency: 20 }
      challenge: auto         # a flooder is asked to prove it is a browser before it is blocked
      challenge_options:
        dry_run: true         # watch-only: shows who WOULD be challenged, challenges nobody

The role is the third one of the same binary: kapkan (the brain), kapkan scrub and now kapkan edge. Installing it end to end is the installation guide; the zones file is the zones reference.

How it works

 client ──TLS──▶ nginx / Angie  (terminates, proxies to your origins)
                    │ auth_request        │ access log (JSON, unix socket)
                    ▼                     ▼
               kapkan edge  ── decision service · clearance page · rollups · ACME · renderer
                    ▲ long-poll of the zone document, self-reports
               kapkan (brain) — zones.yaml, the lever, issuance coordinator, console
  • The brain distributes policy, never verdicts. kapkan edge long-polls one document — the zones with their origins, TLS floor, ACME directories and per-request policy, the rung's signing keys and any lever an operator has pulled — and keeps a copy on disk. Every per-request decision is made on the node; a brain that is down or unreachable changes nothing about how requests are answered.
  • The renderer turns the document into nginx configuration under the node's state directory: one shared file (a JSON log_format, the upstreams for Kapkan's unix sockets, a catch-all that refuses unknown hosts) and one file per zone (the :80 listener that answers ACME challenges and redirects, the :443 server that proxies to the origins behind an auth_request to the decision service, and the clearance page's endpoints). Each generation is tested with nginx -t before it is installed and reloaded; a failing candidate is never installed — the previous configuration keeps serving and the failure is reported.
  • The decision service answers nginx's auth_request over a unix socket in microseconds with one of three words: 200 (with an optional X-Kapkan-Mark header the origin receives), 401clear the rung first, so nginx serves the clearance page in place of the origin — or 403. It enforces the zone's policy.rate per source — an IPv4 address, or an IPv6 /64 — with a token bucket for requests per second and an approximate in-flight count for concurrency, and keeps a bounded table of verdicts (deny, challenge or mark, each with a TTL; a deny outranks a challenge, a challenge a mark). A client over its ceiling is answered 429 with Retry-After; a client the table refuses gets 403; a client the table challenges gets the page.
  • The rollups read the terminator's access log (JSON over a unix datagram socket) into per-zone, per-source windows of ten seconds. A source that keeps pushing through its rate ceiling is promoted: in a zone whose rung is off straight to a deny (one minute, doubling to ten on repeat); in an auto zone to the rung first — the ladder. A source whose requests are almost all errors is marked errors for the origin to see. Verdicts stay on the node; the rollups also feed the node's report, so the brain and the console see who was refused or asked, and who would have been.
  • ACME runs on each node: account keys and certificates are generated on the box and never leave it. The brain only serialises issuance per zone (one node orders at a time) and fans a pending HTTP-01 challenge out to every node that serves the zone, so the CA's validation may land on any of them. Both are advisory: a node renews with the brain gone.

The fast/slow split

Changing a zone's rate, its rung (policy.challenge and its options), a verdict or pulling the lever never reloads nginx; those flow to the decision service and take effect on the next request. Only slow changes — a zone added or removed, an origin changed, the TLS floor, a certificate issued or renewed — produce a new configuration generation, tested and reloaded. This is what lets a rate be tightened or a zone put under challenge in the middle of an attack without touching the terminator: the clearance machinery is rendered for every deciding zone, whatever its rung says, so the bytes are the same for off, manual and auto.

The proof-of-work rung

Between let it through and block it the edge has a middle word: prove you are a browser. A challenged client is served a small page in place of the origin; the page's script solves a hashcash puzzle (a few seconds of CPU at the default difficulty) and posts the answer, which earns a clearance — a signed cookie bound to this zone and this client's source — that lets its requests through the rung for half an hour. A browser barely notices; a script that cannot run JavaScript takes the timed path (below); a bot that only fires requests never gets past it. The rung is not a WAF and not a bot detector: it reads nothing of the request but the path prefix, and only to exempt it; it makes a request cost something, and a client with CPU to spend can clear it — and is then rate-limited like everyone else.

A zone's rung is set by policy.challenge in the zones file: off (the default), manual — every request without a valid clearance is challenged, the switch for a site under attack — or auto — nobody is challenged until the node's rollups or an operator name a source or the whole zone. Both words are watch-only until you say otherwise: challenge_options.dry_run is true by default, so the rung first shows who it would ask (the mark would-challenge:<why>, the report's would-be set, the console) and asks nobody.

The ladder

In an auto zone the rollups' flood rule challenges before it denies. A source that pushes through its rate ceiling for a window is sent to the rung for five minutes (table:flood) — a browser clears it and is rate-limited like anyone; a bot cannot. A source that already had the rung's chance — flooding on while challenged (by name, or with the whole zone), having cleared the rung and flooding anyway, or remembered from an earlier promotion — is denied, one minute doubling to ten on repeat. In the verdict table challenges may fill at most half of the room, so a rotating botnet's challenges can never crowd out the denies that must follow; a flooder whose challenge finds no room is denied instead.

The zone-wide trigger is for the flood no single source trips — residential proxies, a botnet spread thin: challenge_options.auto.zone_rps (per node; 0 = off) flips the whole zone to challenge for auto.hold_seconds (default five minutes) when the zone's admitted rate on the node — decided requests the node did not refuse — runs at or over it; every window still over extends the hold, and it lapses on its own. Refused traffic is not load: a blocked bot's 403s or a lone flooder's 429s never flip a zone or keep it flipped. The trigger is node-local by design — each node measures its own window; the fleet-wide view is the brain's report.

The clearance page

On a 401 from the decision service the terminator serves, in place of the origin, a 403 Cache-Control: no-store HTML page from the node's own clearance service (the fourth unix socket, edge-clearance.sock): the puzzle as a data block, one script and one stylesheet by content hash, a strict CSP, no images, no third parties, in the visitor's language (en, ru, de, fr, es from Accept-Language). A non-GET original — an API call, a form post — gets the compact {"error":"challenge_required"} instead. The script solves the puzzle in lanes (a few Workers plus a time-sliced lane on the main thread, so a background tab's timer throttling cannot stall it) and posts the answer to /_kapkan/clearance/answer; a correct one is answered 303 back to the request's own path with

Set-Cookie: kapkan_clr=…; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800

— host-only, so a sibling zone can never read it, and bound to the zone and the client's source key, so it is useless from another address or on another zone. The cookie's value is the one client value the decision subrequest carries; it is verified against the document's per-zone keys (derived from a fleet master the brain rotates at UTC midnight, the previous day's honoured for 48 hours, persisted with edge.state_file so a brain restart does not make every cleared visitor solve again) — and, last, against a key of the node's own, so a node cut off from the brain still challenges and clears instead of walling everyone out. A cleared request reaches the origin marked cleared; the rate and concurrency ceilings still apply to it, so a flood without cookies is answered with 429s, not with a page per request.

No JavaScript is a first-class path. The page carries a timed ticket, redeemable four seconds to two minutes after issue, both as a <noscript> meta refresh and as a Continue button that stays in place unless the script hides it as its first act — JavaScript off, a blocked or broken script and an engine that fails the solver's self-check all leave the button there, and it comes back beside a solve that runs long. The ticket earns the shorter five-minute clearance (cleared:nojs). Every refusal a browser can meet is a page with the way forward: a too-early ticket retries itself, an expired one and a stale or wrong answer lead back to the page the visitor came from, the issuance cap says to wait a minute. Clearances are capped at 6 per source and 6 000 per zone a minute (429 beyond). Accessibility is a review gate for the page: semantic HTML, one status line announced once, a non-timed alternative to every timer, both colour schemes at ≥ 13:1 text contrast, focus outlines, reduced motion honoured.

Two knobs per zone: challenge_options.difficulty (12..22, default 18; each step doubles the work, and a slow phone must still finish inside the puzzle's two-minute window) and cookie_ttl_seconds (60..86400, default 1800). challenge_options.exempt_paths names path prefixes the rung never challenges — health checks, API clients, webhooks. Should the clearance service itself be down, the 401 follows the zone's failure_mode like any other decision failure: open passes the request undecided, closed answers 503.

The lever

An operator can set a zone's rung for a bounded time, whatever the zones file says:

curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  https://kapkan.example.net:8443/api/v1/edge/zones/shop.example.com/challenge \
  -d '{"mode":"manual","ttl_seconds":1800,"reason":"credential stuffing from residential proxies"}'

mode is manual, auto or off (which clears it, like DELETE); ttl_seconds is 60..86400. The override travels in the zone document — parked polls are woken at once — and every node applies the effective mode on its fast path, reading it per decision, so it ends on time, brain or no brain, and a zone-wide flip it made inert is retired with it. The response says where the lever bites: the file's own mode, the zone's watch-only flags (policy.dry_run and challenge_options.dry_run — on either, the lever previews rather than bites) and every configured node with its liveness and reported dry_run — a node that only counts must be seen before the lever is trusted. It is an operator's action (operator rank; a tenant-scoped operator may pull it on its own zones only — any other zone is 404 unknown zone for it), audited as edge_challenge, named override by GET /api/v1/edge/zones/status — the console's Edge view shows the mode the nodes apply under it (manual, auto), not the lever itself — and held in the brain's memory like the issuance coordinator: an incident's tool, gone after a brain restart (the nodes drop it with the first document the restarted brain serves; pull it again). The brain's own dry_run does not gate it: pulling the lever is a policy edit; watch-only lives on the node and the zone. A node running a version from before the lever does not know the override and follows its file, while the response still lists it — upgrade the nodes before relying on it (the inventory shows each node's version).

Watching before it bites

Like every remote role, an edge node defaults to dry-run, and the edge has three layers of it, each a floor the layer below cannot undo:

LayerWhereCovers
The node's dry_run (edge.yaml, default true)the box ownerevery decision of every zone on this node
The zone's policy.dry_run (default false)the zones fileevery decision of this zone, on every node — a zone can only be more watch-only than its node
The rung's challenge_options.dry_run (default true)the zones filethe rung alone: challenges are previewed, the ceiling and the block enforce as the layers above say

Under the first two a would-be denial reaches the origin as an allow marked X-Kapkan-Mark: would-deny:rate (or would-deny:concurrency, would-deny:table:flood); under any of the three a would-be challenge reaches it as would-challenge:manual, would-challenge:zone:<reason> or would-challenge:table:flood — so you can grep your origin's logs, watch kapkan_edge_decisions_total{result="would_deny"|"would_challenge"} and read who would have been challenged before a single client is asked: every node reports its zones' last window with the busiest sources and the strongest thing it did — or would have done — to each, the brain merges them into GET /api/v1/edge/zones/status, and the console's Edge view shows the would-be set per zone. Go live one layer at a time: dry_run: false on the node when the would-deny counts look right, challenge_options.dry_run: false on a zone when its would-be set names the right clients.

HTTP/3 (QUIC)

A zone with tls.h3: true in the zones file is served over HTTP/3 — QUIC on UDP 443 — beside TLS-over-TCP, on every node whose nginx or Angie can do it. Nothing changes in what Kapkan decides: the same auth_request, the same decision service, the same rung and ceilings over h3 as over TCP; the access log gains a proto field (HTTP/3.0) so the rollups and kapkan_edge_requests_total{protocol="h3"} show how much of a zone's traffic moved.

Where it renders, and where it honestly does not. At start the node asks its binary nginx -V and reports a readiness in terminator.h3.state (the inventory, /healthz, kapkan edge -check):

StateMeaningWhat a zone with h3: true gets on this node
readyThe build carries --with-http_v3_module and edge.yaml has quic.h3: auto (the default)listen 443 quic and an Alt-Svc announcement
no_moduleThe build has no HTTP/3 module (Debian 12's nginx 1.22, for one)TLS over TCP only; the zone is listed under terminator.h3.unsupported
node_offedge.yaml says quic.h3: offthe same
unknownThe probe failed; the node never guesses about a binary it could not askthe same

A zone is never held hostage to one node's package: the document is accepted everywhere, the nodes that can serve h3 do, the others say so, and GET /api/v1/edge/zones/status shows the zone's h3 {enabled, serving, unsupported, requests} across the fleet (the console's Edge view has a cell for it). Turning h3 on or off is a slow-path change — a new tested generation, a reload on every node — like an origin or a certificate; every fast-path knob (rates, the rung, the lever, watch-only) still changes no rendered byte on an h3 zone.

What is node-wide, and why. nginx binds a QUIC connection to the address's default server before SNI names a zone, so three things cannot be per zone and Kapkan does not pretend they are:

  • Retry is always on (quic_retry, at the http level of the shared file; quic.retry: false in edge.yaml turns it off node-wide). A new client address proves it can receive before the handshake costs the node anything, and a Retry is smaller than the Initial it answers, so a spoofed source amplifies nothing. The price is one round trip on a new, unverified connection. There is no "Retry under load": load protection is the data plane's Initial-rate ceiling, below.
  • 0-RTT is off, with no knob: ssl_early_data is address-wide on nginx, and most builds could not use it anyway (OpenSSL ≥ 3.5.1 with nginx ≥ 1.29.1 is the floor — the inventory records early_data_capable per node for the day a policy wants it). Replayable early data in front of a rung that issues clearances is not a trade Kapkan makes for you.
  • The host key (state_dir/tls/quic_host.key, minted once per node, 0600) keeps Retry and stateless-reset tokens valid across reloads; nginx's default — a fresh random key on every reload — would void them exactly when a node reloads under load, and a client may not accept a second Retry.

Rolling it out. A transport has no watch-only mode, so the honest substitute is control over discovery, in this order:

  1. Nodes first. Upgrade every node before a zone says h3: true: a node older than E5 refuses a document that carries the key and stays on its previous generation with converged: false. Check terminator.h3.state in the inventory.
  2. UDP 443 and MTU. Open UDP 443 on the box, the security group and the upstream ACL; terminator.h3.listening says the box's half is bound, an external curl --http3-only says the rest. Announcing h3 while UDP is blocked upstream costs every browser a race and a timeout before it falls back to TCP. QUIC never fragments: a path MTU below 1280 bytes (a GRE/IPIP diversion, an overlay) breaks h3 while TCP survives through MSS clamping — see network integration.
  3. The canary: tls.h3_options.advertise: false renders the QUIC listener without the Alt-Svc announcement, so only clients that already speak HTTP/3 to the name reach it (curl --http3-only), browsers stay on TCP.
  4. A short memory: advertise: true with alt_svc_max_age_seconds: 300, so a rollback is forgotten in minutes, then the default (86400).
  5. Rollback is h3: false — one tested generation per node; clients that remembered the announcement fall back on their next connection and mark h3 broken for the origin for a while.

Builds and advisories. nginx still calls HTTP/3 experimental, and the module has had security advisories of its own. The probe compares the build's nginx core with the published ranges and reports an advisoryadvice, not a verdict: distributions backport fixes without moving the version, so a 1.26.3 may or may not carry the fix, and only the package changelog knows. CVE-2026-40460 (cores 1.25.0–1.30.0) matters to Kapkan specifically: after a QUIC connection migrates, new streams carry an unverified client address, and that address is the accounting key every decision is made on. Debian 13's 1.26.3-3+deb13u7 carries the fix and still reports 1.26.3; nginx.org's stable 1.30.4 and Angie 1.12.1 (core 1.31.2) are past the ranges. Read the advisory before h3: true, or set quic.h3: off on the node until you have.

The two levers, on the data plane. Both live in the XDP data plane in front of the node (or on the same box), never in nginx — a reload at attack speed is the one thing the edge charter forbids:

# kapkan.yaml on the data-plane box
dataplane:
  ratelimit_profiles:
    - { name: quic_handshake_cap, pps: 20 }
  static_rules:
    - name: cap_quic                       # per-source ceiling on QUIC Initials
      match: { proto: udp, dst_port: 443, payload: quic_initial }
      action: ratelimit
      profile: quic_handshake_cap
    # - name: kill_quic                    # the "kill QUIC" lever: clients fall back to TCP
    #   match: { proto: udp, dst_port: 443 }
    #   action: drop

Both hot-reload (kapkan -s reload) and preview under the data plane's dry_run; the cap is the handshake ceiling, the kill rule drops UDP 443 outright and every client is back on TCP within a round trip — the incident answer to a QUIC-only problem. A PoP without a Kapkan data plane in front has only the slow lever, h3: false in the zones file, or the host firewall.

What nginx cannot do, and the topologies Kapkan supports. nginx routes a QUIC connection by the socket it arrived on, not by connection ID, so:

  • Supported: one node per address; several nodes behind a 4-tuple ECMP or anycast hash — a client whose address changes (Wi-Fi to LTE, a NAT rebinding) lands on a node that does not know the connection and reconnects; nothing breaks, per-node host keys are enough; a Kapkan data plane in front of the node or on it for the cap and the kill.
  • Not supported: connection-ID-aware steering and connection migration between nodes (quic_bpf on, an operator's own line in nginx's main context, only carries a connection between the workers of one box); TLS session resumption between nodes — nginx binds a session to the node's certificate through the session id context, and Kapkan's certificates are per node, so a client that changes node does a full handshake (rare under a 4-tuple hash; shared ticket keys were investigated and do not help here); WebSocket over HTTP/3 (nginx does not implement RFC 9220 — it stays on TCP); 0-RTT.

Two things to keep in mind: UDP flows need conntrack timeouts a firewall may not give them by default, and quic_gso (the http context) is yours to set in nginx.conf if you want it — Kapkan renders neither.

Placing zones on nodes

By default every node serves every zone: one document, one fleet. A fleet with PoPs — a US site and an EU site, say — places zones on nodes along the axis Kapkan already has for prefixes, the hostgroup:

# kapkan.yaml
hostgroups:
  - { name: edge-us, networks: ["198.51.100.0/26"] }   # the US PoP's VIP prefix
  - { name: edge-eu, networks: ["198.51.100.64/26"] }  # the EU PoP's VIP prefix
edge:
  nodes:
    - { name: us-1, hostgroups: [edge-us] }           # US zones only
    - { name: eu-1, hostgroups: [edge-eu, global] }   # EU zones and the global ones
    - { name: any-1 }                                 # no scope = the global group alone
# zones.yaml
zones:
  - { name: shop.example.com, hostgroup: edge-us, origins: ["10.0.0.10:8080"] }
  - { name: shop.example.eu,  hostgroup: edge-eu, origins: ["10.0.1.10:8080"] }
  - { name: www.example.com,  origins: ["10.0.2.10:8080"] }          # global

The rule is one sentence: a node serves a zone when the zone's hostgroup is in the node's scope. A zone's hostgroup is zones[].hostgroup, or global when it names none; a node's scope is edge.nodes[].hostgroups, or [global] when it lists none. Consequences, in order of how often they bite:

  • A label by itself is strict. Putting hostgroup: edge-us on a zone takes it off every node that does not list edge-usany-1 above stops serving it. Isolation and the CA's duplicate-certificate budget follow the placement by default; nothing is opened by accident. A zone placed on a group of N nodes is N orders per issuance, not the fleet's — the ceiling in Limits is counted per placement.
  • global is a literal. A node that must serve its PoP's zones and the fleet-wide ones lists both: [edge-eu, global]. A node with only [edge-eu] gets no global zone.
  • The scope follows the addressing. A hostgroup needs at least one prefix inside networks — the PoP's VIP prefix, which you protect anyway. Placement is not a second topology; it is the one you already declared.
  • A zone's address follows its placement — you move it. Kapkan renders configuration; it never steers traffic. Placing a zone on edge-us only reaches clients once the name resolves to the nodes that list edge-us, and the node that no longer serves it stops answering for it the moment the new document lands: the name falls through to the kapkan catch-all, which closes the connection on :80 (nginx's return 444) and refuses the handshake on :443. A request that arrives at the wrong node is a dropped connection, not a 404 or a stale page — an empty reply or a refused handshake at the client, so read those as wrong node, not as a broken origin. A certificate authority is such a client. It resolves the zone the way the world does, so an HTTP-01 validation that lands outside the placement fails as a connection error — move the DNS record (or the anycast prefix) in the same change that moves the zone, and the node that gains it orders on its first poll.
  • Each node gets its own document — exactly the zones its scope covers, with their certificate grants, fanned-out ACME challenges, clearance keys and levers, and its own ETag. A node asking for the issuance slot of, or publishing a challenge for, a zone it does not serve gets the same 404 unknown zone a nonexistent zone gets. The placement itself never enters the document.
  • Scoping requires bound tokens. The moment any node has a scope, every agent token must carry node — a scope enforced against a shared token would be a promise nothing keeps. Fleets without scopes keep their grace.
  • Ownership and placement are two axes. tenant says who may see the zone, hostgroup where it is served; when both the zone and its hostgroup carry a tenant they must agree, and nothing is inherited — a zone in a labelled group is still a house zone unless it says otherwise. The rule is for named hostgroups: the global group is the fleet's catch-all, not a tenant's PoP, so a labelled zone may stay global whatever kapkan.yaml's top-level tenant says. A hostgroup that does not exist fails the reload (the previous zones stay live), like any broken zones file.
  • Never at attack speed. Placement is a slow change like origins or TLS: moving a zone re-renders the nodes it leaves and joins and reloads their terminators. The fast/slow split is untouched.

kapkan -check-config prints the matrix — each node's scope, how many zones it covers, its token — and warns about a zone no node's scope covers (it is served nowhere until a node lists its group). GET /api/v1/edge/zones/status carries each zone's placement {hostgroup, nodes, alive} and flags unserved when its nodes exist but none is alive; the lever's response lists the zone's nodes, not the fleet's; the inventory shows each node's hostgroups and zones_placed. A node's claims about a zone outside its scope are stored in its report but not merged into the zone's status.

Runbook — a zone leaves a node. Moving a zone off a node is an ordinary slow reload there: the terminator stops serving the name, the node's certificates for it stay on disk and stop renewing. Delete them when you are sure, or leave them; nothing renews a certificate for a zone the node no longer serves.

What the node does when things fail

FailureBehaviour
Brain down or unreachableThe node keeps serving the last document and certificates from disk; renewals continue; a reboot comes back into service without the brain (fail-static). A lever in the cached document still ends at its until; the rung's keys stay valid for 48 hours and the node's own key takes over after that. Under a shared or anycast address a renewal's HTTP-01 validation may land on another node while the brain is down; such attempts retry hourly and never count toward the fallback CA.
Decision service down or slowPer zone: failure_mode: open (the default) passes the request undecided and counts it; closed answers 503.
Clearance page downA 401 that cannot be turned into the page follows the zone's failure_mode the same way: open passes the request, closed answers 503.
Document refused (renderer, or nginx -t fails)The previous generation keeps serving; the node stays healthy but converged: false, reports the rendered document's ETag (not the accepted one), and retries locally every 1 → 10 minutes until it applies or a newer document arrives. The fast path — rates, rungs, the lever — is applied from the accepted document meanwhile.
nginx diessystemd restarts it; with terminator.pid_file set the node reports alive: false and answers 503 on its /healthz meanwhile. Kapkan never supervises the terminator itself.
Certificate unrenewableThe current certificate serves until it expires; kapkan_edge_cert_not_after_seconds is the T−30 d alarm; after three failures the node tries the fallback CA.
Node diesThe brain sees the poll stop (edge.stale_after_seconds) and shows it in the inventory; steering traffic away is your network's job (anycast withdraw, DNS).

Reading the node

  • /healthz on status_listen answers 200 while a tested generation of the node's own is live (and the terminator is alive, when a pid file is configured), 503 otherwise. The body carries converged, the rendered and accepted ETags, the live generation and its test result, the last error, the next retry, when the brain was last seen, and dry_run.
  • /metrics on the same address exposes the node's Prometheus series — decisions by result (challenge, would_challenge, allow_cleared among them), the clearance page's outcomes, whether a zone-wide challenge is on — see metrics.
  • The brain's inventory, GET /api/v1/edge/nodes, joins each configured node's liveness — the zones poll, and nothing else — with its last self-report: version, dry-run, rendered ETag, terminator kind and version, its HTTP/3 readiness (terminator.h3.state: ready, no_module, node_off, unknown, with an advisory when the build's nginx core falls in a published QUIC advisory's range), generation and test result, certificates (never a key), and the zones section — each zone's last ten-second window, the sources it refused, asked or would have, and whether its rung bites or previews there. A sighting is stamped when a poll starts and again when it ends, so while a poll is parked last_seen holds that poll's start and a healthy node's sighting is routinely older than edge.stale_after_seconds; holding is what says a poll is open right now, and a parked node is alive whatever last_seen says. See the API.
  • The zones across the fleet, GET /api/v1/edge/zones/status, lists every zone of the zones file and merges the alive nodes' reports into it (a tenant-scoped token gets its own zones): rates summed, the nodes on which a zone is watch-only or under a zone-wide challenge (biting or previewing), the lever in force, and the would-be set. The console's Edge view — shown once edge.nodes[] is configured — renders it; see the dashboard.

Limits to know

  • TLS floor on older nginx. nginx before 1.29.2 applies the default server's ssl_protocols to every name, so the node's catch-all carries the lowest tls.min_version of all its zones and every zone gets that floor. Angie and nginx ≥ 1.29.2 honour the per-zone floor. Where this matters, keep every zone on the same floor or run Angie.
  • One default server. Kapkan renders its own catch-all default_server on :80 and :443 that refuses unknown hosts and SNI. If your nginx.conf already declares one (Debian's default site does), nginx -t fails with a duplicate default server — set omit_catch_all: true and let your servers take that role.
  • Origins resolve at test time. A hostname origin is resolved once, when nginx loads the configuration; one that does not resolve fails nginx -t for the whole generation (and is retried). Prefer addresses.
  • The rung is a cost, not a verdict. A client with CPU to spend clears it (and is then rate-limited like anyone); a client behind a shared address — an office, a carrier NAT — shares its source key with its neighbours, and the six-clearances-a-minute cap per source is theirs together. The rung never reads a request beyond the path prefix it may exempt: it is not a WAF and does not try to be one.
  • The zone-wide trigger is per node, and the lever lives in the brain's memory: a brain restart forgets it, and the nodes drop it with the first document the restarted brain serves — pull it again. A brain that stays down is different: the cached document's lever ends at its until (see the failure table).
  • Wildcards are a later milestone. Every name is listed explicitly.
  • HTTP/3 is per zone, rendered where the node can, node-wide in what nginx decides before SNI (Retry on, 0-RTT off), with no connection migration or session resumption between nodes — see HTTP/3 (QUIC).
  • Duplicate-certificate ceilings. Per-node certificates mean the N nodes a zone is placed on are N orders (the whole fleet for a global zone); Let's Encrypt caps a name at about five per week. The brain's issuance slot and renewal jitter spread them; acme.fallback (ZeroSSL, Google Trust Services — both need an External Account Binding) is the escape hatch. Plan CA accounts for large fleets.
  • The agent token issues certificates — for its node's zones. Bind each agent token to its node (api.tokens[].node, see authentication) so a stolen token cannot act as another node, and place zones on nodes so its node serves only the zones it should: a stolen bound token can then take the issuance slot and publish a challenge answer only for the zones its node's scope covers (every call logged), and a node compromise is a certificate exposure for those zones, not the fleet's. Rotate that node's token; the brain holds nothing to steal.