go53 is a young, fast-moving authoritative DNS server written in Go — API-driven, container-native, and built so operators can reason about exactly what their nameserver is doing. 0.80.0 closes out the summer cycle. It rolls up the 0.79.1 hotfix, the 0.79.2 point release we never wrote about, and the change that gives this release its number: DNS names are now canonical the moment they are written, so the query path never has to go looking for them.
If you last upgraded after reading the 0.79.0 post, this is everything that changed since. Here’s the engineer’s tour.
ALIAS: a CNAME you can put at the apex
The DNS spec forbids a CNAME at the zone apex, and yet every operator eventually needs example.com. to follow a load balancer hostname that changes its addresses without telling anyone. go53 now has an ALIAS pseudo-record for exactly that:
curl -X POST http://127.0.0.1:8053/api/zones/example.com./records/ALIAS \
-H 'Content-Type: application/json' \
-d '{"name":"@","ttl":60,"target":"edge.provider.net."}'
ALIAS is stored like any other record type, under a private-use type code, but it is never served on the wire. A background sweep resolves the target roughly once a minute and materialises real A and AAAA records through the ordinary store path. That detail matters more than it sounds: because the flattened records are ordinary records, DNSSEC signing, AXFR, NOTIFY and Merkle anti-entropy all work on them unchanged. There is no special case anywhere downstream.
In distributed mode ALIAS is multi-master. Any node may run the sweep and write; flattened entries carry an alias marker and a resolved_at stamp, and a node skips its own write while a peer’s is still fresh. Each write publishes the stored value back through the event log, so peers converge byte-identically via the existing per-entity vector clocks. Delete the ALIAS and the flattened A/AAAA records are cleaned up cluster-wide. Secondaries never flatten anything themselves — they receive the flattened records over AXFR, exactly like a BIND secondary would expect.
There is a two-node end-to-end script in the repo that exercises all of this — flattening, replication, multi-master writes to either node, and cleanup — and it runs offline against localhost., so you can run it on a laptop.
Canonical names at write time
This is the 0.80.0 headline (#43, #58). Until now the in-memory store tolerated whatever casing and trailing-dot convention a writer used, and papered over the differences on read: if an exact lookup missed, it fell back to a case-insensitive scan over the zone’s records. That worked, but it meant every query that didn’t hit exactly paid for a linear scan — on the hot path.
0.80.0 flips the responsibility. Every zone and owner name is normalised to a lowercase FQDN when it is written, and lookups are now plain exact-key matches. The fallback scans are gone. Two things were done carefully:
- Legacy data is migrated, not abandoned. On first start after the upgrade, go53 rewrites any mixed-case or non-FQDN keys it finds in storage to canonical form, merges duplicate zone keys that only differed in case, persists the result, and drops the legacy keys. Records that would otherwise have become unreachable keep working.
- Case-sensitive material stays untouched. NSEC3 maps are keyed by uppercase base32 hashes and RRSIG maps by covered-type mnemonics; those keys are deliberately excluded from folding.
The same class of bug had already bitten distributed mode in 0.79.1 (#53): the HTTP handlers passed the raw URL zone (example.com) into the replication helpers while records were stored under the FQDN (example.com.). Every add reported a spurious “stored record not found after add”, and worse, delete tombstones never matched, so anti-entropy resurrected deleted records on peers. Both were fixed by canonicalising the zone before publishing events. 0.80.0 makes that fix structural rather than local.
Long TXT and DKIM records no longer SERVFAIL
A critical fix from 0.79.1 (#52) that already got its own post, recapped here because it deserves it. TXT and SPF values longer than 255 bytes must be split into multiple character-strings on the wire. go53 did that on the AXFR and DNSSEC-signing path — but not on the query answer path. A 2048-bit DKIM key is about 400 bytes, so the message failed to pack and go53 returned nothing, which resolvers report as SERVFAIL. 1024-bit keys are under the limit, which is why the bug hid for so long.
Serve and signing now share one chunking implementation, so character-string boundaries are identical and RRSIGs validate. The split is precomputed once on write, not per query. Keep passing the full value in a single text field; go53 does the rest.
A quieter, faster query path
Two performance items from 0.79.1 that operators running real traffic will notice:
- Query logging behind log levels (#42). The per-query path used unconditional
log.Printfcalls, which format, write and take the logger mutex on every single query. Request-path detail is nowDebug; genuine security rejections (unknown NOTIFY source, TSIG failures, unauthorised AXFR) stay atWarn. At the default level a debug call is one lock-free comparison. As part of this,log_levelin the config is finally honoured — and it applies on every live-config commit, so a PATCH takes effect without a restart. - Lock-free config reads (#45). Live config is now read through an
atomic.Pointersnapshot instead of a mutex, so the request path no longer contends with config updates.
Small fixes that were annoying
- Freshly added A records were served with TTL 3600 until the next restart, regardless of what you configured — the in-memory shape and the persisted shape disagreed on the number type. Fixed, with a regression test that pins the served TTL right after add.
- AAAA records written in one particular in-memory shape (for example by the ALIAS flattener) weren’t answered until a persistence round-trip. Fixed the same way.
- The OpenAPI spec now names MX payload fields
priorityandhost, matching RFC 1035, and spells out when the recordnameis relative (POST) versus an absolute FQDN (PATCH/DELETE).
Testing the upgrade itself
Because 0.80.0 rewrites storage keys on first boot, we wanted proof that an upgrade doesn’t eat data. There is now a script that starts a published go53 image in a podman pod, seeds a zone with a broad set of record types onto a persistent Badger volume, then starts the build under test on the same volume and asserts every served record survives byte-for-byte. It also checks that ALIAS is accepted on the upgraded node. It runs in CI and it runs on your machine.
Upgrade
Prebuilt binaries for Linux, macOS, and Windows (amd64/arm64) are on the 0.80.0 release page, alongside container images. Two notes for this one:
- The first start after upgrading performs a one-time key migration and persists it. It is designed to be safe — but it is also exactly the moment a base backup is cheap insurance.
- If you run distributed mode, upgrade all nodes; the zone-key fix from 0.79.1 is what makes deletes converge.
Want to kick the tires without installing anything? There’s a live instance at demo.go53.eu, and the project lives at go53.eu. As always: go53 is local-first, operator-friendly, and open. Three of the fixes above started as issues filed by people running it — keep them coming.