
Wir haben unseren eigenen DNS-Server gebaut
Jonas ScholzWir haben einen produktiven DNS-Server in ~1000 Zeilen Go geschrieben, tausende Records von Hetzner DNS migriert und die Propagation-Zeit von "bis zu 90 Minuten" auf wenige Sekunden reduziert. Genutzt werden das Hidden-Primary-Pattern, Postgres als Event-Bus und AXFR + IXFR, um die Zonen an öffentliche Secondaries zu pushen. Hier ist, wie und warum wir das gemacht haben!
Warum Hetzner DNS für uns nicht mehr funktioniert hat
Jeder Service auf Sliplane bekommt eine managed Subdomain wie my-app-abc123.sliplane.app. Das heißt: ein A- und ein AAAA-Record für jeden laufenden Service, der auf die Server-IP des Containers zeigt. Records skalieren also linear mit der Plattform.
Wir haben mit Hetzner DNS angefangen, weil es kostenlos war und wir den Großteil unserer Infra schon dort betrieben haben. Das hat eine Weile gut funktioniert, aber nach 2 Jahren sind wir an zwei Wände gelaufen:
Record-Limits: Hetzner DNS hat ein hartes Limit pro Zone. Ursprünglich 500, sie haben es für uns auf 10k erhöht (Hetzner Support ist super ❤️🔥), aber bei unserer Wachstumsrate wären wir innerhalb von Wochen drüber. Wir sind anscheinend einer ihrer größten DNS-Nutzer nach Record-Anzahl :D
Geschwindigkeit: Nachdem ein Record über die API erstellt wurde, konnte es bis zu 90 Minuten dauern, bis Hetzners eigene Nameserver ihn auch zurückgegeben haben. Für ein PaaS, bei dem jemand gerade einen Service deployed hat und die URL aufrufen will, ist das eine ziemlich raue Erfahrung. Auch wenn das nicht durchgehend so schlimm war, hat es jedes Mal direkt die User Experience getroffen. Es sah einfach so aus, als wäre unsere Plattform kaputt (was sie in dem Moment ja auch war!).
Warum nicht einfach einen anderen Managed Provider?
Faire Frage. Für die meisten Leute ist ein Managed DNS Provider die richtige Antwort. Aber sobald man bei unserer "Größe" und unseren Anforderungen anfängt zu vergleichen, wird es schnell nervig:
"Contact Sales" Pricing. Viele der Provider, die unsere Record-Anzahl problemlos handhaben könnten, sitzen hinter "Talk to Sales" Anfragen. Ich hasse das. Sag mir einfach, was es kostet.
Per-Record oder Per-Query Billing. Die, die ihre Preise veröffentlichen, rechnen oft pro Record oder pro Query ab. Wir haben keine Ahnung, wie viele DNS-Queries wir tatsächlich beantworten, und auf ein unbekanntes Pricing-Modell zu wechseln fühlte sich an wie einen Blankoscheck zu unterschreiben.
Nur EU. Wir sitzen in der EU und wollten DNS auch dort behalten. Das schränkt das Feld stark ein.
Und ehrlich gesagt, es klang nach Spaß. Ich bin ein bisschen ein Controlfreak und einen DNS-Server zu schreiben ist eine dieser Sachen, von denen man träumt. Tausend Zeilen Go fühlten sich die Freiheit wert an. Am Ende hat das Bauen weniger Zeit gekostet, als ein Meeting mit einem Managed Provider zu bekommen 😵💫
Also haben wir es selbst gebaut, und das bringt uns zum Pattern, das es überraschend einfach gemacht hat.
Das Hidden-Primary-Pattern
Der Grund, warum das Ganze viel einfacher ist, als ich anfangs dachte: unser DNS-Server beantwortet keine einzige öffentliche Query.
In DNS hält der Primary Nameserver einer Zone die autoritativen Records. Secondaries ziehen Kopien per AXFR (im Grunde ein vollständiger Zone-Dump über TCP) und beantworten öffentliche Queries genauso wie der Primary. Wenn sich der Primary ändert, schickt er ein NOTIFY an die Secondaries, und die ziehen sich eine frische Kopie.
Ein Hidden Primary geht einen Schritt weiter, der Primary ist gar nicht öffentlich. Er existiert nur, um Zone-Daten an Secondaries zu pushen. Die öffentlichen Nameserver, also die, die beim Registrar eingetragen sind, sind alle Secondaries.
Das heißt, wir können unseren DNS-Server laufen lassen, wo wir wollen, jeden Secondary-Provider nutzen, der AXFR unterstützt, und Provider wechseln, ohne unseren Server anzufassen. Kein Lock-in, weil AXFR und NOTIFY Standard-Protokolle sind, jeder konforme Secondary funktioniert.
Kein Anycast, keine super redundanten DDoS-geschützten DNS-Server rund um den Globus. Nur ein paar Instanzen unseres versteckten Primary-Servers.
Die Architektur

Das Setup ist ziemlich minimal:
Postgres ist die Source of Truth. Wir installieren Trigger, die pg_notify('dns_zone_changed', '') aufrufen, sobald ein Service erstellt, geändert oder gelöscht wird. Keine Message Queue, keine Webhooks. Postgres ist der Event-Bus.
Warum nicht Redis, NATS oder eine richtige Queue? Zwei Gründe. Wir betreiben Postgres sowieso schon als unsere Hauptdatenbank, also ist LISTEN/NOTIFY "kostenlose" (gibt kein Free Lunch, aber so frei wie es geht) Infrastruktur, nichts Neues zum Betreiben, Monitoren oder Bezahlen. Und das Volume ist winzig. Zone-Änderungen passieren ein paar Mal pro Minute zu Spitzenzeiten, was lächerlich wenig ist für irgendwas Queue-förmiges. Hier nach Kafka zu greifen wäre, als würde man einen Schiffscontainer mieten, um eine Postkarte zu verschicken.
sliplane-dns ist ein kleiner Go-Server (~1000 Zeilen, basierend auf miekg/dns), der per LISTEN subscribed, Postgres nach allen managed Domains und ihren IPs abfragt, die DNS-Zone baut und sie per AXFR ausliefert.
Um unnötige Arbeit zu vermeiden, hashen wir alle Records. Wenn der Hash mit der vorherigen Zone übereinstimmt, passiert nichts, kein Serial-Bump, kein NOTIFY. Wenn sich die Zone tatsächlich ändert, bumpen wir die SOA-Serial und schicken ein DNS NOTIFY an die drei Secondary-IPs von Hetzner. Die ziehen die neue Zone, und die Records sind live.
Um zu zeigen, wie ein Zone Transfer tatsächlich aussieht, hier ein minimaler DNS-Server, der nur AXFR spricht. Er liefert eine hardcoded Zone für example.com mit einem einzigen A-Record (vollständiger Code auf GitHub):
package main
import (
"context"
"log"
"net/netip"
"codeberg.org/miekg/dns"
"codeberg.org/miekg/dns/rdata"
)
func main() {
soa := &dns.SOA{
Hdr: dns.Header{Name: "example.com.", TTL: 3600, Class: dns.ClassINET},
SOA: rdata.SOA{Ns: "ns1.example.com.", Mbox: "admin.example.com.", Serial: 1},
}
records := []dns.RR{
soa,
&dns.A{
Hdr: dns.Header{Name: "app.example.com.", TTL: 300, Class: dns.ClassINET},
A: rdata.A{Addr: netip.MustParseAddr("1.2.3.4")},
},
soa,
}
mux := dns.NewServeMux()
mux.HandleFunc("example.com.", func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) {
r.Unpack()
w.Hijack()
env := make(chan *dns.Envelope, len(records))
for _, rr := range records {
env <- &dns.Envelope{Answer: []dns.RR{rr}}
}
close(env)
dns.NewClient().TransferOut(w, r, env)
w.Close()
})
srv := dns.NewServer()
srv.Addr = ":5553"
srv.Net = "tcp"
srv.Handler = mux
log.Fatal(srv.ListenAndServe())
}
Starte ihn und zieh die Zone mit dig:
dig @localhost -p 5553 example.com AXFR
example.com. 3600 IN SOA ns1.example.com. admin.example.com. 1 0 0 0 0
app.example.com. 300 IN A 1.2.3.4
example.com. 3600 IN SOA ns1.example.com. admin.example.com. 1 0 0 0 0
Der vollständige Zone Transfer ist einfach SOA, alle Records, SOA nochmal. Das ist ungefähr das, was Hetzners Secondaries von unserem Production-Server ziehen, nur mit ein paar tausend mehr Records zwischen den beiden SOAs.
Samstagnacht-DNS-OP
Man kann DNS-Nameserver nicht graduell migrieren. Die NS-Records beim Registrar zeigen entweder auf das alte oder das neue Set. Es gibt ein Cutover-Fenster, da führt kein Weg dran vorbei.
Wir mussten von Hetzners Nameservern (hydrogen.ns.hetzner.com, oxygen.ns.hetzner.com, helium.ns.hetzner.de) auf die Secondary-Nameserver von Hetzner Robot (ns1.first-ns.de, robotns2.second-ns.de, robotns3.second-ns.com) wechseln.
Während der Umstellung würden Resolver mit gecachten alten NS-Records weiterhin die alten Server fragen und veraltete Daten bekommen, bis die TTL abläuft. Zwei Sachen haben das handhabbar gemacht: die TTL der NS-Delegation lag bei 5 Minuten, und nur neue Services, die in dem Fenster deployed wurden, waren betroffen. Bestehende A/AAAA-Records waren auf beiden Nameserver-Sets identisch.
Wir haben es an einem Samstagabend gemacht, als die Plattform-Aktivität am niedrigsten war. Lief glatt, kein User hat sich beschwert!
Das Eine, was uns kalt erwischt hat: IXFR
Ich bin in das Projekt gegangen mit der Annahme, AXFR würde reichen. Es ist das Protokoll, das jedes Tutorial zeigt, jedes Beispiel benutzt, und es ist das, was ich zuerst gebaut habe. Vollständiger Zone-Dump, SOA am Anfang, SOA am Ende, fertig.
Stellt sich raus, dass die Secondaries von Hetzner Robot nicht nur AXFR machen. Wenn sie eine Zone schon haben und per NOTIFY eine neue SOA-Serial sehen, fragen sie zuerst nach einem inkrementellen Zone Transfer (IXFR, RFC 1995), also einem Diff von nur den Records, die sich seit der alten Serial geändert haben. Wenn der Primary kein IXFR spricht, fällt ein wohlerzogener Secondary auf AXFR zurück. Hetzner Robot scheint in nicht jedem Fall sauber zurückzufallen, also wurden Zonen nicht zuverlässig aktualisiert, bis wir IXFR auch implementiert hatten.
IXFR ist nicht schwer, du hältst einfach eine kleine Historie der letzten Zone-Versionen und gibst auf Anfrage das Delta zwischen der Serial des Clients und der aktuellen zurück. Aber es ist die Art von Sache, die du nur entdeckst, wenn du es tatsächlich gegen einen echten Secondary deployst. Hut ab vor dem, der das RFC geschrieben hat.
War es das wert?
Bisher zu 100%. Propagation ging von "bis zu 90 Minuten" auf so lange, wie ein Zone Transfer dauert, was bei unserer Zonengröße praktisch sofort ist. Die Zone wächst mit der Plattform, ohne in irgendein Record-Limit zu laufen, und wir haben jetzt auch volle Observability eingebaut.
Solltest du das auch machen?
Wahrscheinlich nicht. Nutz Cloudflare DNS, Route 53 oder was auch immer dein Provider an Managed DNS anbietet. Die sind schnell, funktionieren, und du musst nicht drüber nachdenken.
Aber wenn du doch mal an die Limits eines Managed DNS Providers stößt, ist das Hidden-Primary-Pattern es wert, im Hinterkopf zu haben. Dein Primary muss nicht öffentlich sein, du kannst jeden AXFR-kompatiblen Secondary nutzen, und du kannst Provider wechseln, ohne deinen Server anzufassen.
Cheers,
Jonas, Co-Founder sliplane.io