Skip to content

knotea: one binary that is both your recursive resolver and your authoritative DNS

knotea is a single Go binary that is at once an authoritative nameserver and a recursive, ad-blocking resolver - the Cloudflare model of auth and resolver behind one query path. A query for a name in a served zone is answered authoritatively; anything else is filtered (policy, then ad-block, then cache) and recursed. It owns public :53 UDP/TCP, :853 for DNS-over-TLS, and :8080 for an HTTP dashboard plus DNS-over-HTTPS, and it supervises two specialists as child processes: unbound for recursion with DNSSEC, and knotd for the authoritative zones. RFC 2136 UPDATE and zone transfers are relayed inward to knotd.

TL;DR: One merged binary replaced two projects (glory-hole the resolver, knot-fly the authority) so the authoritative zones live in the same process as the resolver - a query for your own zone never leaves the box, which sidesteps the Fly anycast UDP hairpin and failures like it. Live record edits go through knotctl, a TSIG-signed RFC 21361 CLI. It runs on Fly.io anycast as the production nameserver for the zones it serves.

This is the big sibling of two smaller DNS setups documented elsewhere here: the Gloryhole DoT / VyOS interop guide, which exposes this same resolver over DNS-over-TLS through the edge router, and Unbound + AdGuard Home on a travel router, a standalone recursive resolver for a single device class. knotea is what the resolver grew into once it also took over authoritative duty.


They started as separate projects - glory-hole (the resolver) and knot-fly (the authority) - and were merged so the authoritative data sits in the same process as the resolver. When this box is asked for one of its own zones, the query is answered locally instead of recursing out to a public resolver and back. On Fly.io it is required, because Fly blocks inbound UDP to customer-anycast IPs from inside Fly2 - so a resolver running on Fly that has to recurse to itself for a Fly-hosted zone returns SERVFAIL. Running knotd in-process on loopback removes that dependency entirely.

Both subprojects keep their full git history under their subdirectories; each still builds standalone. A root go.work links the two modules.

clientknotea:53 :853(DoT) :8080(DoH+UI)policy engineexpr rulesotherwiseknotd127.0.0.1:5354 authoritativename in served zonead-block~9M-domain listsunbound127.0.0.1:5353 recursion+DNSSECrecurse

The recursive side combines ad-blocking, an expression-based policy engine, local zones, and a web dashboard in the one binary, with unbound doing the recursion and DNSSEC validation.

  • Blocklists. Downloaded, deduplicated, and atomically swapped in; exact, wildcard, and regex patterns with optional auto-update. Defaults are the hagezi and OISD lists. Whitelists short-circuit a block. Reads are lock-free (atomic.Pointer) so a list reload never stalls a query.
  • Policy engine. expr rules3 with helpers like DomainMatches, IPInCIDR, InTimeRange; actions BLOCK, ALLOW, REDIRECT, FORWARD. Compiled once, evaluated in order, with a visual builder and tester in the UI.
  • Upstream health. A circuit breaker on the forwarder (opens after consecutive failures, half-opens to probe), plus one TCP retry after a UDP SERVFAIL - that retry exists specifically to work around the Fly UDP hairpin block.
  • Telemetry. Prometheus on :9090/metrics, a SQLite query log (WAL, batched async writes), and host CPU/memory/temperature in the dashboard. The dashboard is Astro + React, embedded with go:embed.

Measured (from the repo’s perf harness): a full-handler profile of 166 ns/op at 7 allocs - the UDP socket saturates long before the Go code does. Same-region latency: cached UDP around 4.3 ms, cold recursion around 4.0 ms, DoH warm 3 ms at p50, a fresh DoT handshake around 19.6 ms. ECDSA P-256 DoT handshakes run about 1.8x the rate of RSA-2048, which is why the cert type changed.

knotd (Knot DNS)4 serves the two zones, both DNSSEC-signed and validating - ECDSAP256SHA256 with KSK and ZSK, online signing, DS records published at the parent. Zone data is operator state held in knotd’s confdb and journal, added at runtime; the declarative source of truth is a set of YAML zonefiles reconciled onto the server.

TSIG keys are scoped by role, so no consumer holds more power than it needs:

KeyMay update
caddy-acme._acme-challenge.* TXT only (the ACME DNS-01 path)
caddy-ddns.A/AAAA dynamic updates
knotctl.general operator records
axfr-out.outbound zone transfers to secondaries

knotctl is the operator CLI. It speaks TSIG-signed RFC 2136 UPDATE1 to the server’s public :53, which the resolver relays byte-for-byte to the loopback knotd.

Terminal window
knotctl add www.example.com A 192.0.2.10
knotctl ls example.com
knotctl set example.com TXT "v=spf1 -all"
knotctl apply zones/example.com.yml --prune

Subcommands: add, rm, set, ls, apply, export, keys. Three of them do more than their name says:

  • add polls until the record is queryable and exits non-zero on timeout - so a script that chains on it does not race the zone.
  • set bundles the delete-rrset and the new records into one UPDATE message, so there is no empty or partial window.
  • apply reconciles declarative YAML - additive by default, --prune removes drift, and it never touches apex NS, SOA, or DNSSEC records. Idempotent; multi-value rrsets are atomic.

Key material lives one-file-per-identity under ~/.config/knotctl/keys/, mode 0600 or it is refused. Never pass a secret with -y on the command line - it lands in shell history.

Runs as a single Fly.io app (region sin) on anycast.2 The port mapping: 80 to the dashboard, 443 to app-terminated TLS, 53 UDP/TCP, 853 DoT. One shared-cpu VM with 512 MB. Fly machines pull images server-side and cannot authenticate to ghcr, so deploys pull the CI-built image, retag to the Fly registry, push, and deploy by image reference.

The serving authority moved once more on 2026-09-18, in a blue-green cutover. The original app kept serving while a second Fly app stood up on a dedicated anycast address with a fresh volume and the repo-managed seed; once every zone was verified serving from the new app, the TLD glue and the DS records flipped to it in the registrar config, and the original app was retired the same day - machine destroyed, its dedicated anycast released. The released address cannot be re-allocated, so a rollback is the cutover re-run in reverse (new machine, new address, glue and DS repointed), rather than a pointer flip. Keeping the registrar side as code is what makes that tolerable: the serving address is a single local in the OpenTofu config, so the next cutover changes that one local in the same commit that moves the NS. Daily backups of the non-regeneratable state (KASP signing keys, timers, confdb export) land on R2 with 14-day retention, and the rebuild runbook is drill-tested against a scratch app.

The registrar side: glue and DS as infrastructure

Section titled “The registrar side: glue and DS as infrastructure”

The authority on Fly is one half of the deployment. The other half is at the registrar: who the TLD points to, what address the in-bailiwick NS resolves to, and which DS record pins the zone’s DNSSEC key. On 2026-09-05 that half failed silently. The registrar’s glue push to the TLD registry never landed, so the registry held no A records for the in-bailiwick NS. The failure mode is a loop: a recursive resolver following the referral must resolve the NS itself, and resolving the NS requires querying the NS it is trying to resolve. The glue A record in the referral is what breaks the cycle. The registrar’s UI showed the glue records the whole time; the registry showed the delegation with no A records behind it.

The registrar side of the domain portfolio is now declarative OpenTofu. Per-domain NS, the glue address (centralised in one local), and the DS records for the in-zone delegations are state in git, imported from the live registrar account, with a drift check and SOPS-encrypted state behind a pre-commit gate. OpenTofu cannot force the registry to serve glue - the registry still has to put the A record in the referral. What the repo adds is a checked-in verification: a query of the TLD registry itself for the NS A records, checking the ADDITIONAL section rather than the +short output that hides it. The registrar-side state can report the glue as pushed while the registry never received it - the 2026-09-05 failure was exactly that gap - so the check that matters is the registry-side one.

The zones were moved off Cloudflare by mirroring over AXFR (CF Enterprise can do outgoing transfers to a TSIG peer), then promoting Knot to primary and swapping the registry NS records. The full runbook is in the authority subproject’s docs. What the migration turned up:

  • Cloudflare’s documented NOTIFY source IPs are wrong. NOTIFYs arrive from CF’s full anycast ranges plus Fly-internal ranges. Allowlist the runtime-fetched CF list, or Knot logs ACL, denied, action notify and your IXFR silently falls behind.
  • The first CF AXFR returns BADKEY for about 30 seconds. Wait it out; do not roll back.
  • The registry NS swap is the one irreversible-until-TTL step. Everything before it is a no-op to roll back; after it you wait out the parent TTL. Add the new NS alongside the old and verify before removing, rather than a full swap.
  • Any Caddy site still on dns cloudflare breaks silently once recursive caches expire after the cutover - migrate every site block to the dns rfc2136 provider within the TTL soak window.
  • TSIG rotation is confdb-first. knotd’s confdb is the source of truth; rotate there before consumer stores, verify the old key gets BADSIG, and only then clean up. A past incident baked live TSIG secrets into config comment lines for ~29h via a sed that matched placeholders in comments - the entrypoint now fails closed if a secret appears in a comment.
  • knotc conf-set is runtime-only in file mode; run knotd with -C <confdb-dir>4 or your changes vanish on restart.
  • sub-or-equal does not match ACME owners. update-owner-match: sub-or-equal does not match _acme-challenge.<host> - use a pattern covering _acme-challenge and its wildcard forms.
  • Single region is a SPOF. A second Knot machine pulling over Fly’s internal network is the recorded plan, not yet built.
  • After a cutover, verify a fresh issuance, not just the surviving certificates. The first new certificate issued after the September 2026 edge-proxy cutover failed with dns: bad authentication: the ACME TSIG key in the router’s secret store was a rotated-out value, and because the certificate store had been preserved through the cutover, nothing on the new binary had exercised the RFC 2136 path. The cutover checklist only checked already-issued certificates; a throwaway vhost’s certificate is what proves the path end to end.

The latency figures above are same-region (client and server both in sin), so they are the floor: a resolver on anycast is only that fast for a client near a PoP, and cross-region recursion is distance-dominated the same way any round trip is. The 166 ns/op handler profile is a single-core microbenchmark; it shows the Go code is not the bottleneck, and it does not show the box sustains that rate, because the UDP socket and the harness saturate first. Size for the network.

Run knotea (or this auth+resolver-in-one pattern) when you want your own zones and your recursive/filtering DNS under one roof with one deploy, and you are comfortable operating DNSSEC and TSIG yourself. Do not take on the authoritative half just to get ad-blocking - if all you want is filtered recursive DNS for clients, the Unbound + AdGuard Home travel-router setup is far less to run, and if you only need to expose the resolver over DoT through an edge router, the VyOS interop guide covers that without touching authoritative DNS.

  1. P. Vixie et al., “Dynamic Updates in the Domain Name System (DNS UPDATE),” RFC 2136, IETF. https://www.rfc-editor.org/rfc/rfc2136 ↩ ↩2

  2. Fly.io, “Networking on Fly.io (anycast, public/private networking).” https://fly.io/docs/networking/ ↩ ↩2

  3. expr-lang, “expr - expression language for Go.” https://expr-lang.org ↩

  4. CZ.NIC, “Knot DNS 3.5 Documentation - Configuration (confdb, TSIG keys, dynamic updates).” https://www.knot-dns.cz/docs/3.5/singlehtml/index.html ↩ ↩2