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.techto 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:
- Public —
frao.tech/stratmgr.com, answered by ns1 + ns2, visible to the entire internet. - Internal —
frao.internal, plus the split-horizon copy offrao.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:
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 to127.0.0.1and 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 monitorevery 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.