Zones reference
The zones file is the list of websites your edge nodes front: for each zone
its origins, its TLS floor, which certificate authority to use and the per-request policy the
node's decision service enforces. It lives on the brain, in its own file named by
edge.zones_file in kapkan.yaml, because zones are tenant data with their own change
cadence — the file is loaded and validated with the configuration, and a broken zones file
keeps the previous zones live rather than serving a half-broken document.
zones:
- name: shop.example.com # a lower-case hostname; no wildcards
tenant: "shop" # optional owner label (multi-tenancy); absent = house zone
hostgroup: edge-eu # optional placement: served by nodes listing this group; absent = global
origins: # at least one host:port; IPv6 as [addr]:port
- "10.0.0.10:8080"
- "10.0.0.11:8080"
tls:
min_version: "1.2" # "1.2" (default) or "1.3"
h3: false # true = also serve over HTTP/3 (QUIC) where the node can
# h3_options: { advertise: true, alt_svc_max_age_seconds: 86400 } # only with h3: true
acme:
directory: "" # this zone's CA; empty = the node's default
fallback: "" # tried after repeated failures; empty = node default
policy:
mode: decide # decide (default) | none
failure_mode: open # open (default) | closed
dry_run: false # true = this zone is watch-only on every node
challenge: auto # off (default) | manual | auto — the proof-of-work rung
challenge_options:
dry_run: true # true (default) = the rung only shows who it would ask
exempt_paths: ["/healthz", "/api/"]
difficulty: 18 # 12..22 leading zero bits
cookie_ttl_seconds: 1800 # how long a clearance lasts, 60..86400
auto:
zone_rps: 2000 # zone-wide challenge at this admitted rate per node; 0 = off
hold_seconds: 300 # how long the zone stays under challenge, 30..3600
rate:
rps: 50 # requests per second per source; 0 = no ceiling
concurrency: 20 # in-flight requests per source; 0 = no ceiling
extra_directives_file: "" # optional nginx snippet, included last
Validate with kapkan -check-config /etc/kapkan/config.yaml (it follows edge.zones_file),
and generate the JSON schema for your editor with kapkan -dump-zones-schema — the committed
copy is docs/zones-schema.json, regenerated with make -C engine schema; the test suite fails
on a stale copy.
Keys
| Key | Meaning |
|---|---|
zones[].name | The zone's hostname, as the certificate will be issued for it: lower-case RFC 1123 labels, no wildcard, no trailing dot, not an IP address, at most 238 characters. Upper case is folded. Names must be unique. |
zones[].tenant | Optional ownership label, on the same axis as a hostgroup's tenant ([A-Za-z0-9._-], at most 64 characters). An API token scoped to it sees the zone in GET /api/v1/edge/zones/status, reads its edge history and telling sources, and, as an operator, may pull its lever; a zone without a label is visible to unscoped tokens only. Ownership, not placement, and never inherited — not from kapkan.yaml's top-level tenant. A token may be scoped to a tenant that only zones use: an edge-only customer with hostnames and no prefixes. The label never enters the document the nodes render, so labelling a fleet's zones reloads nothing. |
zones[].hostgroup | Where the zone is served (placing zones on nodes): the name of a hostgroup in kapkan.yaml, or global. A node serves the zone when its edge.nodes[].hostgroups lists that group; absent, the zone is in the global group, served by every node that lists global — which is every node without a scope. So a label by itself takes the zone off every node that does not list it, and a fleet that never set one is untouched. The hostgroup must exist (checked when the brain loads both files — a typo would serve the zone nowhere), and when both the group and the zone carry a tenant they must agree; nothing is inherited. Placement is not ownership (tenant) and never enters the document: each node gets exactly the zones its scope covers. |
zones[].origins[] | Where nginx proxies the zone's requests: one or more host:port upstreams, an IPv6 address bracketed ([2001:db8::10]:8080), the port 1..65535 without a leading zero. A hostname origin is resolved once, when nginx loads the configuration; one that does not resolve fails the whole generation. Duplicates are refused. |
zones[].tls.min_version | The lowest TLS version the zone accepts: "1.2" (default) or "1.3". With TLS 1.2 the node renders the modern ECDHE cipher set; with 1.3 only TLS 1.3 suites. On nginx before 1.29.2 the lowest floor of all zones on a node applies to every zone (the default server's ssl_protocols wins); Angie and nginx ≥ 1.29.2 honour it per zone. |
zones[].tls.h3 | true serves the zone over HTTP/3 (QUIC, UDP 443) beside TLS-over-TCP, false (default) does not. Rendered only on the nodes whose nginx or Angie carries --with-http_v3_module and whose edge.yaml does not say quic.h3: off; elsewhere the zone is served over TCP and the node's report names it (terminator.h3.unsupported; the zone's row in GET /api/v1/edge/zones/status lists the node under h3.unsupported, a mode: none zone included). The zone needs its certificate first (no TLS server, no QUIC). h3 and h3_options are rendered configuration, like origins and tls.min_version: changing them is a new tested generation and a reload on every node; the fast-path knobs (policy.rate, policy.challenge, dry_run, the lever) never change rendered bytes. Upgrade the nodes first: a node older than this release refuses a document that carries h3, stays on its previous generation with converged: false, and cannot install anything after that — renewed certificates included — until h3 is removed or the node upgraded. Retry is on by default and 0-RTT off, node-wide by nginx's design; the node's edge.yaml quic.retry is the only place Retry can be turned off. |
zones[].tls.h3_options.advertise | true (default) announces HTTP/3 with an Alt-Svc header on the zone's TLS-over-TCP responses; false renders the QUIC listener without announcing it — the canary: only clients that already speak HTTP/3 to the name reach it (curl --http3-only), browsers stay on TCP. A transport has no watch-only mode; this is the honest substitute. Changing it is a reload. Refused without tls.h3: true. |
zones[].tls.h3_options.alt_svc_max_age_seconds | Alt-Svc's ma: how long a client may remember the alternative, 60..604800 (default 86400). A short value is the rollout step between the silent canary and the default, and bounds how long browsers keep trying HTTP/3 after you turn it off. Changing it is a reload. Refused without tls.h3: true. |
zones[].acme.directory | The ACME directory URL this zone's certificate is ordered from (a staging or private CA, for instance). Empty means the node's default, which is Let's Encrypt production unless the node's edge.yaml says otherwise. An http(s) URL with a host. |
zones[].acme.fallback | A second directory a node turns to after three consecutive failures with the primary (the answer to a duplicate-certificate ceiling on a fleet), alternating from then on; a success from either clears the failure state and the following renewal tries the primary first. Must differ from acme.directory. Empty means the node default, which may be none. A CA that requires an External Account Binding (ZeroSSL, Google Trust Services) needs its credentials in the node's edge.yaml — the zones file carries no secrets. |
zones[].policy.mode | decide (default): every request to the zone asks the node's decision service through nginx's auth_request. none: proxy only — no decision, no rate, no marks; the zone still gets its certificate and its access log. |
zones[].policy.failure_mode | What nginx does when the decision service is down, slow or off-contract: open (default) passes the request undecided — the edge's equivalent of the data plane's default-pass — and counts it; closed answers 503. With open the failure is absorbed inside the subrequest, so the client's keepalive connection survives it; with closed nginx answers 503 on that connection. |
zones[].policy.dry_run | true makes this zone watch-only on every node: its denies and challenges are counted and marked (would-deny:<reason>, would-challenge:<why>) and none is enforced, while sibling zones enforce as before. The node's own dry_run (edge.yaml) is the floor a zone cannot go below — a zone can only be more watch-only than its node. Default false: the zone follows its node. A node older than this release does not know the key and enforces the zone under its own dry_run; upgrade the nodes first. |
zones[].policy.challenge | The proof-of-work rung (the clearance page): off (default), manual or auto. manual: every request without a valid clearance is sent to the page. auto: a source is challenged when the node's rollups say so — a flooder is challenged before it is denied, and the whole zone when challenge_options.auto.zone_rps trips — or when an operator pulls the lever. A valid clearance passes the rung, never the rate ceiling. Watch-only until challenge_options.dry_run is false. Changing it never reloads nginx. |
zones[].policy.challenge_options.dry_run | The rung's own watch-only switch, true by default: a challenge is answered as an allow marked would-challenge:<why>, so you see who would have been asked before any zone asks anyone. Set false to let the rung bite (the node's and the zone's dry_run still win). |
zones[].policy.challenge_options.exempt_paths[] | Request-path prefixes the rung never challenges — health checks, API clients, webhooks: absolute paths, matched as prefixes of the request path without its query, in both the normalised and the raw form (a path with a dot segment, ;, a backslash, a control byte or a surviving %-escape is never exempt). The one place the edge reads a path, for an exemption only — never for a verdict. At most 64. |
zones[].policy.challenge_options.difficulty | The puzzle's leading zero bits, 12..22 (default 18: a few seconds in a browser's Worker lanes; each step doubles the work). A slow device must still finish inside the puzzle's two-minute window; the page offers the timed no-JS path beside a long solve. |
zones[].policy.challenge_options.cookie_ttl_seconds | How long a solved puzzle's clearance lasts, 60..86400 (default 1800). A cleared client solves again when it expires or when its address (IPv6: /64) changes. The no-JS ticket's clearance is fixed at five minutes. |
zones[].policy.challenge_options.auto.zone_rps | For challenge: auto: the zone's admitted request rate on one node — decided requests the node did not refuse — at or over which every source of the zone is challenged (the flood spread over so many sources that none trips its own ceiling). 0 (default) sets no zone-wide trigger. Node-local: each node measures its own window. |
zones[].policy.challenge_options.auto.hold_seconds | How long the zone-wide challenge stays on after a window that tripped it, 30..3600 (default 300); every window still over the rate extends it, and it lapses on its own. |
zones[].policy.rate.rps | Requests per second per source — an IPv4 address, or an IPv6 /64 — enforced by the decision service with a token bucket whose burst is one second's worth. 0 (default) sets no ceiling. Changing it never reloads nginx. |
zones[].policy.rate.concurrency | In-flight requests per source, counted approximately from decisions and completions in the access log (a source whose log stream goes quiet has its ceiling suspended rather than wrongly enforced). 0 (default) sets no ceiling. |
zones[].extra_directives_file | An absolute path on the node to an nginx snippet included last in the zone's :443 server — the one escape hatch (an extra header, a custom location). You own what it breaks; nginx -t is the only guard, and a snippet that fails it keeps the previous generation serving. No whitespace, ;, #, braces, quotes, backslash, $ or glob characters (* ? [ ]) in the path. |
What a policy does to a request
With mode: decide, nginx asks the node's decision service before proxying. The answer is
200 — the request goes to the origin, with X-Kapkan-Mark set when the source carries a mark
— 401 — the source must clear the rung first, so nginx serves the clearance
page instead of the origin — or 403 with a reason:
| Reason | nginx answers | Set by |
|---|---|---|
rate | 429 with Retry-After: 1 | The source exceeded policy.rate.rps. |
concurrency | 429 with Retry-After: 1 | The source has policy.rate.concurrency requests in flight. |
table:<reason> | 403 | The source is in the node's verdict table: promoted by the rollups (table:flood — a source that kept pushing through its rate ceiling is denied for one minute, doubling to ten on repeat). The dry-run mark is would-deny:table:<reason>. |
challenge:manual | the clearance page (403, never cached) | policy.challenge: manual — or a manual lever — and the request carries no valid clearance. |
challenge:zone:<reason> | the clearance page | The whole zone is under its auto.zone_rps trigger (zone-rps). |
challenge:table:<reason> | the clearance page | The source is in the verdict table with a challenge: the rollups' flood rule in an auto zone (table:flood) — the rung a flooder gets before the block. |
A request that clears the rung reaches the origin marked cleared (or cleared:nojs for the
timed no-JS ticket) — the rate ceiling still applies to it. A source that floods on while
challenged, or that had cleared the rung and floods anyway, is denied.
Two headers are under Kapkan's control and can never be forged by a client: X-Kapkan-Zone
(the zone name) always reaches the origin; X-Kapkan-Mark is present only when the source
carries a mark and absent otherwise — a client-sent value of either is dropped. A source whose
requests are almost all 4xx/5xx is marked errors. In dry-run the origin sees exactly
what live mode would have refused or asked: under the node's dry_run or the zone's
policy.dry_run every would-be denial becomes an allow marked would-deny:<reason> and every
would-be challenge one marked would-challenge:<why>; under the rung's own
challenge_options.dry_run only the challenge is previewed (would-challenge:<why>) while the
ceiling and the block enforce as before.
The node keeps a bounded number of sources per zone; when the table is full a new source is passed untracked (default-pass) and counted, never refused for lack of room.
Certificates
Each zone gets a certificate per node, issued and renewed on the node by HTTP-01; the
private key never leaves the box. Renewal starts 30 days before expiry (a third of the lifetime
for shorter certificates), jittered per node and zone. Until the first certificate arrives the
zone's :80 listener answers 503 for everything but the ACME challenge path — a zone is
never proxied over cleartext. Every name is issued explicitly; wildcards (DNS-01) are a later
milestone.
The CA reaches the zone over its public address, exactly as a visitor does, and the brain
publishes the pending challenge in the document of every node the zone is placed
on — so whichever of them the validation lands on answers
it. The corollary is the one that bites: a zone whose name does not resolve to a node that
serves it cannot be issued at all, and a node outside the placement refuses the connection
rather than answering the challenge path. Point the name at the placement before you expect a
certificate. A validation also leaves a trace: the CA's probes are a handful of requests in the
zone's windows, undecided 2xx, which is why an idle zone's history
is not quite empty around an issuance.
Related
- Edge nodes — the role, its failure modes and limits
- Install an edge node
kapkan edge— the node's ownedge.yaml- Configuration — the brain's
edgeblock