Under the Hood: Frao's Self-Hosted Authoritative DNS Stack

Under the Hood: Frao’s Self-Hosted Authoritative DNS Stack

The companion post to "Owning Our Address on the Internet." That one explains why we took over our own DNS; this one is how — the architecture that serves frao.tech to the internet, the internal namespace that exists only inside our network, the single-writer path that keeps every change auditable, and the measured latency of a record edit on its way to the public internet.
Reading time: ~6 minutes


The Stack

Five components, two planes, two nameservers, one writer:

Component Host Role Wire
C1 pdns-public IONOS Public primary — authoritative for frao.tech / stratmgr.com 87.106.237.243:53
C2 pdns-secondary Vultr Public secondary — AXFR from C1 45.77.93.55:53
C3 pdns-internal IONOS Internal view — frao.internal + split-horizon frao.tech 127.0.0.1:5300
C4 pdns-recursor IONOS Recursor for VPN clients — forwards internal, recurses the rest 10.64.0.5:53
C5 frao-dns IONOS Orchestrator — the only writer to DNS 127.0.0.1:8080

Two planes:

  • Publicfrao.tech / stratmgr.com, answered by ns1 + ns2, visible to the entire internet.
  • Internalfrao.internal, plus the split-horizon copy of frao.tech, answered only to VPN clients through the recursor.

Two Namespaces, One Domain: Public vs Internal

The core design decision: one domain, two answers — and the internet can only ever see one of them. Split-horizon means the same name resolves differently depending on where the question is asked:

Name From the internet From a VPN client
dev.demo.frao.tech Public IP of the service Internal WG address 10.64.0.5
postgres.prod.frao.internal NXDOMAIN — doesn’t exist Internal address 10.128.0.5
frao.tech Public authoritative answer Same answer, or internal per service

The mechanism is a forwarding rule, not magic:

recursor (C4) 10.64.0.5:53
  ├─ frao.internal        → internal view (C3) :5300
  ├─ frao.tech (internal) → internal view (C3) :5300
  └─ everything else      → recurse to the public internet

A VPN client points its resolver at 10.64.0.5. Names in the internal namespace never leave our network: an external resolver querying postgres.prod.frao.internal gets NXDOMAIN, because the public plane holds no such record and the internal view is reachable only from inside. Service discovery stays private; the public zone stays clean.

Client config — the only change a VPN client needs:

[Interface]
DNS = 10.64.0.5        # dev network (prod peers: 10.128.0.5)

The Single-Writer Path

DNS is changed one way only: through C5. frao-dns keeps a SQLite registry of every managed record, derives the change, and applies it to PowerDNS over its REST API. Zone files are a seed, never a live edit target.

frao-dns record set --plane public --ttl 60 frao.tech flash.frao.tech A 1.1.1.1

That one command wrote to the registry, pushed the RRSet to the primary, and — once the secondary refreshed — the whole internet served the new record.

Change latency, measured across three tiers:

Time to converge after a record change (seconds)

Authoritative answers flip in under a second. Recursive resolvers follow within the TTL we choose (60 s in the live exercise). The one latency we don’t control is the parent registry’s delegation cache (~15 min) — and that applies only to registrar-level changes, never to zone records.

Redundancy: Two Nameservers, Two Locations

The public plane is served by two independent authoritative servers on different providers and ASNs — IONOS (ns1) and Vultr (ns2). C2 AXFRs from C1, and the transfer is restricted at the source:

# pdns-public.conf
local-address=87.106.237.243
local-port=53
allow-axfr-ips=45.77.93.55/32      # only the Vultr secondary may transfer
api=yes

If ns1 fails, the internet still resolves through ns2. If both authoritative servers fail, the zone is unreachable — which is exactly why monitoring is non-negotiable (below). The delegation uses glue records at the registrar, so the in-bailiwick ns1.frao.tech / ns2.frao.tech resolve cleanly.

Security & Operations

  • API surface is loopback-only. PowerDNS REST (:8081/:8082) and C5 (:8080) bind to 127.0.0.1 and require an API key. The internet cannot reach them.
  • One writer, full audit trail. Every record change flows through C5’s registry — no hand-edited zone files, no unaccounted edits.
  • Monitoring. A systemd timer runs make monitor every 5 minutes: container health, listener checks, and live resolution probes against both nameservers, with alerting on failure.
  • Backup & DR. Encrypted database backups (make backup) and a documented disaster-recovery playbook; deploy/zones/ reseeds a fresh environment from scratch.
  • DNSSEC is deliberately not yet enabled — the zone is unsigned today. Signing is a planned follow-up and must be sequenced with DS publication to avoid a SERVFAIL window.

What We Verified

The cutover was rehearsed on staging, then executed so the served records never changed: identical content, new authority. After go-live we ran the "live flip" — stamped a 60-second-TTL record, re-pointed it, and watched both nameservers and public resolvers converge within the TTL. make doctor (both hosts) and ./scripts/f0-cutover.sh --check (serials, glue, delegation) are the standing gates.


Want the Why?

This is the how. For the plain-language version — what owning our nameservers means for the platform, our partners, and our clients — see Owning Our Address on the Internet.