DNS as Code: Mit DNSControl und mittwald viele Domains in Git verwalten

|

DNS-Einträge pflegst du meistens per Klick – und genau so sehen sie nach ein paar Jahren auch aus. Mit DNSControl beschreibst du deine Zonen stattdessen als Code, legst sie in Git ab und lässt Änderungen automatisch ausrollen. Wie das mit Domains im mStudio funktioniert, zeige ich dir in diesem Beitrag.

Kennst du das? In einer Zone steht ein TXT-Record, den niemand mehr zuordnen kann. Eine Verifizierung für irgendein Tool, irgendwann von irgendwem angelegt. Löschen? Lieber nicht. Wer weiß, was dann kaputtgeht. Also bleibt er stehen, so wie die 3 anderen davor.

Wer 40 Kunden-Domains betreut, hat davon nicht einen, sondern Dutzende. Dazu sieht der SPF-Record überall ein bisschen anders aus. Der DKIM-Schlüssel fehlt bei der Hälfte. Und wer was wann geändert hat, weiß niemand mehr. Die Lösung dafür ist dieselbe wie bei Servern und Deployments: Der gewünschte Zustand steht in einer Datei. Ein Tool sorgt dafür, dass die Realität dazu passt.

Was ist DNS as Code?

DNS as Code heißt: Du klickst Records nicht mehr zusammen, sondern beschreibst deklarativ, wie eine Zone aussehen soll. DNSControl ist ein Open Source Tool dafür, ursprünglich bei Stack Overflow entstanden. Es liest deine Beschreibung, vergleicht sie mit dem, was beim DNS-Anbieter tatsächlich eingetragen ist, und gleicht die Unterschiede aus.

3 Befehle reichen für den Alltag:

  • dnscontrol check: Prüft Syntax und allgemeine DNS-Regeln, ohne die API anzufassen.
  • dnscontrol preview: Prüft zusätzlich die Regeln des Anbieters und zeigt, was sich ändern würde. Der Befehl ändert nichts.
  • dnscontrol push: Setzt die Änderungen um.

Die Konfiguration ist JavaScript. Das klingt erst einmal nach mehr Aufwand als YAML, ist aber der eigentliche Trick: Du hast Variablen und Funktionen. Und damit alles, was du brauchst, um Wiederholungen loszuwerden.

DNSControl spricht mit über 60 Anbietern. Seit Kurzem gehört auch mittwald dazu: Der Provider MITTWALD verwaltet die DNS-Einträge von Domains, die in einem mStudio Projekt liegen.

Welche Vorteile bringt DNSControl bei vielen Domains?

  • Versionierung: Jede Änderung ist ein Commit. git log beantwortet, wer wann was geändert hat, und ein git revert mit anschließendem push macht es rückgängig.
  • Reviews: DNS-Änderungen laufen als Pull Request durch dieselbe Prüfung wie Code. Vier Augen sehen den Tippfehler im MX-Record, bevor keine Mails mehr ankommen.
  • Vorschau: preview zeigt dir vorher exakt, welche Records DNSControl anlegt, ändert oder löscht.
  • Einheitlichkeit: Einen Baustein, zum Beispiel für die Mail Records, definierst du einmal und verwendest ihn überall. Abweichungen zwischen Domains entstehen gar nicht erst.
  • Automatisierung: Nach dem Merge rollt eine Pipeline die Änderung aus. Niemand braucht dafür einen Login im mStudio.
  • Dokumentation: Ein Kommentar über dem Record erklärt, wofür er da ist. Der TXT-Record, den niemand zuordnen kann, gehört damit der Vergangenheit an.
  • KI-Agenten: Liegen die Records als Code im Repository, kann sie auch dein KI-Agent pflegen. Er ändert eine Datei und öffnet einen Pull Request. preview zeigt die Auswirkungen. Die Freigabe läuft wie bei jeder anderen Änderung.

Je mehr Domains du verwaltest, desto stärker wirken diese Punkte. Bei einer Domain sparst du dir ein paar Klicks. Bei 50 stellst du den SPF-Record für alle mit einer geänderten Zeile um.

DNSControl mit mittwald einrichten

DNSControl ist ein einzelnes Binary. Ich installiere es mit mise und halte die Version direkt im Repository fest. So arbeiten alle im Team und die Pipeline mit demselben Stand:

user@local $ mise use github:DNSControl/dnscontrol@5.4.0
user@local $ dnscontrol version
v5.4.0

Für den Zugriff brauchst du einen API-Token, den du im mStudio in deinem Benutzerprofil anlegst. Der Token gehört zu einem mStudio User. DNSControl findet darüber jede Domain in den Projekten, auf die dieser User zugreifen darf.

Die Zugangsdaten stehen in der creds.json. Der Token selbst gehört nicht in die Datei und schon gar nicht ins Repository. DNSControl liest ihn aus einer Umgebungsvariable, wenn der Wert mit $ beginnt:

{
  "mittwald": {
    "TYPE": "MITTWALD",
    "api_token": "$MITTWALD_API_TOKEN"
  }
}

Die erste Zone als Code

Die zentrale Datei heißt dnsconfig.js. Sie legt den Provider und die Standardwerte fest und lädt alles Weitere nach:

var REG_NONE = NewRegistrar("none");
var DSP_MITTWALD = NewDnsProvider("mittwald");

DEFAULTS(DefaultTTL("1h"));

require("./lib/records.js");
require_glob("./domains/");

require_glob lädt jede .js-Datei im Ordner domains/. Eine neue Zone ist damit eine neue Datei. Die dnsconfig.js fasst du dafür nicht mehr an.

Das Repository sieht am Ende so aus:

.
├── .github/workflows/dns.yml
├── domains/
│   ├── agentur.js
│   └── kunden.js
├── lib/records.js
├── creds.json
├── dnsconfig.js
└── mise.toml

Eine Zone selbst sieht so aus, hier ein erster Stand für die Domain unserer Beispielagentur Pixelwerk. Domains, Adressen und Tokens in diesem Beitrag sind Platzhalter:

D("pixelwerk.example", REG_NONE, DnsProvider(DSP_MITTWALD),
  A("@", "198.51.100.10"),
  AAAA("@", "2001:db8:100::10"),
  A("www", "198.51.100.10"),
  AAAA("www", "2001:db8:100::10"),

  CNAME("status", "pixelwerk.statuspage.example."),
  TXT("_github-challenge-pixelwerk", "a1b2c3d4e5")
);

Eine Besonderheit von mittwald solltest du dabei kennen: Ist eine Domain im mStudio mit einem Projekt verbunden, setzt mStudio die A- und AAAA-Records dafür automatisch, ebenso die MX-Records für den Mail Service. Du könntest sie also weglassen. Der Provider zeigt diese verwalteten Einträge dann weder an noch verändert er sie.

Ich definiere sie trotzdem als Code. Sobald du für einen Namen eigene A-, AAAA- oder MX-Records deklarierst, ersetzen sie die verwalteten Einträge. Ab dann gilt, was im Repository steht. Der Vorteil: Wer die Datei liest, sieht alle Records für Website und Mail und muss nicht wissen, was an anderer Stelle noch dazukommt. Der Preis dafür: Zieht ein Projekt um, änderst du die Adresse selbst, per Pull Request.

Wichtig dabei: A und AAAA bilden bei mittwald einen gemeinsamen Satz. Deklarierst du nur die IPv4-Adresse, ist die verwaltete IPv6-Adresse ebenfalls weg. Gib deshalb immer beide an.

Lokal probierst du das so aus. Der Token steht dabei nur in der Umgebungsvariable:

user@local $ export MITTWALD_API_TOKEN=...
user@local $ dnscontrol check
user@local $ dnscontrol preview
user@local $ dnscontrol push

Records deduplizieren: Search Console, SPF und DKIM nur einmal definieren

Jetzt kommt der Teil, für den sich JavaScript lohnt. Fast jede Domain braucht dieselben Dinge: die Records für die Website, den Verifizierungs-Record für die Google Search Console, MX, SPF und DKIM. Statt sie in jede Zone zu kopieren, definierst du sie einmal in lib/records.js.

Die Verifizierung für die Search Console ist überall gleich aufgebaut, nur der Token unterscheidet sich. Das ist eine Funktion mit einem Parameter:

// Google Search Console ownership proof.
function GOOGLE_SITE_VERIFICATION(token) {
  return TXT("@", "google-site-verification=" + token);
}

Genauso funktionieren die 4 Website Records von eben. Sie unterscheiden sich von Domain zu Domain nur in den Adressen. Und eine Funktion darf auch mehrere Records auf einmal liefern:

// Website hosted in an mStudio project, reachable with and without "www".
function MITTWALD_WEBSITE(ipv4, ipv6) {
  return [
    A("@", ipv4),
    AAAA("@", ipv6),
    A("www", ipv4),
    AAAA("www", ipv6),
  ];
}

Spannender wird es bei den Mail Records. Wer seine Postfächer bei mittwald hat, braucht für jede Domain dasselbe Paket: die MX-Records für den Empfang, einen SPF-Record, der die Versand-Server von mittwald erlaubt, und die beiden DKIM-Schlüssel.

Die DKIM-Schlüssel sind dabei der Klassiker für Copy-and-paste-Fehler: mehrere 100 Zeichen, bei denen ein fehlendes Zeichen am Ende niemandem auffällt. Sie stehen deshalb genau einmal im Repository, hier gekürzt dargestellt:

// Public DKIM keys of the mittwald mail servers, one per selector.
var MITTWALD_DKIM_KEYS = {
  agenturserver: "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3U8kq...",
  agenturserver2048: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1BM9...",
};

MITTWALD_MAIL fasst alles zusammen, was du für E-Mails über mittwald brauchst:

// Mail is handled by the mittwald mail servers.
var MITTWALD_MAIL = [
  MX("@", 10, "mx1.agenturserver.de."),
  MX("@", 10, "mx2.agenturserver.de."),
  MX("@", 20, "mx3.agenturserver.de."),
  MX("@", 50, "mx4.agenturserver.de."),
  SPF_BUILDER({
    label: "@",
    parts: ["v=spf1", "include:agenturserver.de", "~all"],
  }),
  TXT("agenturserver._domainkey", "v=DKIM1; k=rsa; p=" + MITTWALD_DKIM_KEYS.agenturserver),
  TXT("agenturserver2048._domainkey", "v=DKIM1; k=rsa; p=" + MITTWALD_DKIM_KEYS.agenturserver2048),
];

// No server may send mail for the domain.
var NO_OUTBOUND_MAIL = TXT("@", "v=spf1 -all");

SPF_BUILDER bringt DNSControl mit und setzt den Record aus seinen Teilen zusammen. Das Gegenstück NO_OUTBOUND_MAIL ist für Domains gedacht, über die du keine Mails verschickst: Der Record erklärt, dass kein Server im Namen der Domain senden darf.

In den Zonen bleiben damit nur noch Zeilen übrig, die sich wie eine Checkliste lesen. Aus der Zone von Pixelwerk wird:

D("pixelwerk.example", REG_NONE, DnsProvider(DSP_MITTWALD),
  MITTWALD_WEBSITE("198.51.100.10", "2001:db8:100::10"),
  GOOGLE_SITE_VERIFICATION("pXw3rk-8Hq2LmZ0aT5vNc7YdR1uJ4bE6sK9fG"),
  MITTWALD_MAIL,

  CNAME("status", "pixelwerk.statuspage.example."),
  TXT("_github-challenge-pixelwerk", "a1b2c3d4e5")
);

Und so sehen 2 Kunden-Domains aus:

D("kaffeeroesterei-nord.example", REG_NONE, DnsProvider(DSP_MITTWALD),
  MITTWALD_WEBSITE("198.51.100.21", "2001:db8:100::21"),
  GOOGLE_SITE_VERIFICATION("kN0rd-4Tz8QwE2rY6uI1oP3aS5dF7gH9jK0lZ"),
  MITTWALD_MAIL
);

D("tischlerei-brandt.example", REG_NONE, DnsProvider(DSP_MITTWALD),
  MITTWALD_WEBSITE("198.51.100.22", "2001:db8:100::22"),
  GOOGLE_SITE_VERIFICATION("tBr4ndt-2Mx7VbN5cQ8wE1rT3yU6iO9pA0sD"),
  MITTWALD_MAIL,
  CNAME("termine", "brandt.booking.example.")
);

Pro Domain steht nur noch da, was sie besonders macht: ihre Adressen, ihr Token für die Search Console und bei der Tischlerei eine Subdomain für die Terminbuchung. Alles andere kommt aus den Bausteinen. Nach demselben Muster baust du dir weitere, etwa für die Verifizierung bei anderen Diensten oder für die Records deines Newsletter Tools.

Was das im Alltag bedeutet, zeigt ein Beispiel. Angenommen, du stellst den SPF-Record im Baustein MITTWALD_MAIL von ~all auf das strengere -all um. Das ist ein geändertes Zeichen in einer Datei:

   SPF_BUILDER({
     label: "@",
-    parts: ["v=spf1", "include:agenturserver.de", "~all"],
+    parts: ["v=spf1", "include:agenturserver.de", "-all"],
   }),

preview zeigt, was daraus wird:

******************** Domain: pixelwerk.example
1 correction (mittwald)
#1: ± MODIFY pixelwerk.example TXT ("v=spf1 include:agenturserver.de ~all" ttl=3600) -> ("v=spf1 include:agenturserver.de -all" ttl=3600)
******************** Domain: kaffeeroesterei-nord.example
1 correction (mittwald)
#1: ± MODIFY kaffeeroesterei-nord.example TXT ("v=spf1 include:agenturserver.de ~all" ttl=3600) -> ("v=spf1 include:agenturserver.de -all" ttl=3600)
******************** Domain: tischlerei-brandt.example
1 correction (mittwald)
#1: ± MODIFY tischlerei-brandt.example TXT ("v=spf1 include:agenturserver.de ~all" ttl=3600) -> ("v=spf1 include:agenturserver.de -all" ttl=3600)
Done. 3 corrections.

3 Domains, 1 Zeile Diff im Pull Request. Bei 50 Domains sieht der Diff genauso aus.

Bestehende Domains übernehmen: vom mStudio in den Code

In der Praxis startest du selten mit einer leeren Zone. Die Domains existieren, die Records sind über Jahre gewachsen. Genau die sollen jetzt ins Repository. Abtippen musst du sie nicht: dnscontrol get-zones liest eine bestehende Zone aus und gibt sie als Code aus.

user@local $ dnscontrol get-zones --format=js mittwald - tischlerei-brandt.example > domains/tischlerei-brandt.js

Das erste Argument ist der Name des Eintrags in der creds.json. Der Bindestrich steht für den Provider-Typ, den DNSControl ebenfalls von dort liest. Danach folgt die Domain. In der Datei steht dann:

// generated by get-zones. This is 'a decent first draft' and requires editing.

var DSP_MITTWALD = NewDnsProvider("mittwald", "");
var REG_CHANGEME = NewRegistrar("none");

D("tischlerei-brandt.example", REG_CHANGEME,
	DnsProvider(DSP_MITTWALD),
	DefaultTTL(3600),
	TXT("@", "google-site-verification=tBr4ndt-2Mx7VbN5cQ8wE1rT3yU6iO9pA0sD"),
	TXT("@", "v=spf1 include:agenturserver.de ~all"),
	CNAME("termine", "brandt.booking.example."),
	// [...]
);

Wie der Kommentar in der ersten Zeile sagt, ist das ein Entwurf. Die beiden var-Zeilen löschst du, weil Provider und Registrar schon in der dnsconfig.js stehen. Aus REG_CHANGEME wird REG_NONE. Danach ersetzt du die Records, für die es einen Baustein gibt. Ob du dabei etwas verändert hast, sagt dir preview. Das Ziel ist diese Ausgabe:

******************** Domain: tischlerei-brandt.example
Done. 0 corrections.

Bei 0 Korrekturen sind Code und mStudio deckungsgleich. Erst ab diesem Punkt gehört die Domain in die Pipeline. Dabei solltest du 3 Dinge wissen:

  • Eine Domain pro Aufruf: get-zones all, das bei anderen Anbietern alle Zonen auf einmal exportiert, unterstützt der mittwald Provider nicht. Bei vielen Domains hilft eine kleine Schleife in der Shell.
  • Verwaltete Records fehlen im Export: Die A-, AAAA- und MX-Records, die mStudio selbst gesetzt hat, zeigt der Provider nicht an. Die Werte dafür liest du in der DNS-Ansicht der Domain im mStudio ab.
  • Schrittweise geht auch: Mit NO_PURGE in der Domain löscht DNSControl nichts, was nicht im Code steht. So kannst du erst einmal nur einen Teil der Records verwalten und den Rest später nachziehen.

Bei der Gelegenheit lohnt sich der Blick auf jeden einzelnen Record: Was niemand mehr zuordnen kann, bekommt entweder einen Kommentar oder fliegt raus.

Automatisieren mit GitHub Actions

Richtig rund wird das Ganze, wenn niemand mehr lokal push ausführt. Die Regel dahinter ist einfach: main ist der Live-Stand, ein Pull Request ist eine Vorschau. Der Workflow hat dafür 3 Jobs:

  • check läuft immer.
  • preview läuft bei jedem Pull Request aus dem eigenen Repository.
  • push läuft bei jedem Push auf main, also nach dem Merge.
name: DNS

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

# At most one deployment runs at a time; a waiting one is replaced by a newer
# one. Previews only run for the newest commit of a pull request.
concurrency:
  group: dns-${{ github.event_name == 'push' && 'deploy' || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

defaults:
  run:
    # Explicit bash runs with pipefail, so a failing dnscontrol fails the step.
    shell: bash

jobs:
  check:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1
        with:
          version: 2026.7.18
      - run: dnscontrol check

  preview:
    # Pull requests from forks do not get the token.
    if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
    needs: check
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1
        with:
          version: 2026.7.18
      - name: Show planned changes
        env:
          MITTWALD_API_TOKEN: ${{ secrets.MITTWALD_API_TOKEN }}
        run: |
          {
            echo '```'
            dnscontrol --no-colors preview 2>&1
            echo '```'
          } | tee -a "$GITHUB_STEP_SUMMARY"

  push:
    if: github.event_name == 'push'
    needs: check
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1
        with:
          version: 2026.7.18
      - name: Apply changes
        env:
          MITTWALD_API_TOKEN: ${{ secrets.MITTWALD_API_TOKEN }}
        run: dnscontrol --no-colors push

Ein paar Details sind bewusst so gewählt:

  • Vorschau im Pull Request: Die Ausgabe von preview landet in der Job-Zusammenfassung. Wer den Pull Request prüft, sieht nicht nur den geänderten Code, sondern auch die tatsächlichen Auswirkungen auf jede Zone.
  • Token als Secret: Der Token liegt als Repository Secret MITTWALD_API_TOKEN in den Einstellungen des Repositorys. Der Workflow reicht ihn per env nur an den einen Schritt weiter, der ihn braucht.
  • Wer darf was: Wer im Repository einen Branch anlegen darf, kann auch den Workflow ändern und damit den Token benutzen. Gib Schreibrechte deshalb nur Personen, die auch DNS ändern dürften.
  • Gepinnte Versionen: Die Actions sind auf einen Commit festgelegt, mise und DNSControl auf eine Version. Ein Update ist damit eine bewusste Entscheidung, die selbst durch ein Review läuft.

Da der Token zu einem User gehört, lohnt sich für die Pipeline ein eigener mStudio User. Gib ihm nur Zugriff auf die Projekte, deren Domains im Repository stehen.

Was du vor dem Start wissen solltest

DNSControl spricht mit jedem Anbieter über dessen API. Jede API hat ihre eigenen Regeln. Der jeweilige Provider kennt sie und prüft sie bei preview, bevor er etwas ändert. Diese 4 Punkte helfen dir beim Einstieg:

  • Was nicht im Code steht, löscht DNSControl: DNSControl gleicht die Zone vollständig an die Konfiguration an. Führe bei einer bestehenden Domain deshalb immer zuerst preview aus und sieh dir jede geplante Löschung an.
  • Nicht jeder Anbieter kann jeden Record: Bei mittwald sind das zurzeit Wildcards (*), NS-Records und ein Null-MX. preview bricht in dem Fall mit einer Fehlermeldung ab.
  • TTLs haben Grenzen: mittwald erlaubt 60 bis 86.400 Sekunden, Werte außerhalb dieses Bereichs passt der Provider an. Alle Records eines Namens und Typs teilen sich eine TTL. A und AAAA zählen dabei zusammen.
  • Die Domain muss schon da sein: Der mittwald Provider verwaltet Records, bestellt aber keine Domains. Eine Domain lässt sich verwalten, sobald sie Teil eines mStudio Projekts ist.

Was dein Anbieter im Detail unterstützt, steht in der jeweiligen Provider-Dokumentation, für mittwald in der Dokumentation des mittwald Providers.

Fazit

Der TXT-Record, den niemand zuordnen kann, ist kein DNS-Problem. Er ist ein Problem fehlender Nachvollziehbarkeit, und genau die liefert Git. Mit DNSControl steht jede Zone als lesbare Datei im Repository, jede Änderung geht durch ein Review, und nach dem Merge erledigt die Pipeline den Rest.

Der größte Gewinn liegt bei vielen Domains. Bausteine für Website, Search Console und Mail definierst du einmal. Eine Kunden-Domain besteht dann nur noch aus wenigen Zeilen. Und eine Änderung am Baustein erreicht alle Domains im selben Lauf. Einheitlichkeit ist dann keine Frage der Disziplin mehr.

Wenn du es ausprobieren willst, fang klein an: eine Domain, check und preview, noch ohne Pipeline. Das komplette Beispiel aus diesem Beitrag findest du im Demo Repository, alle Funktionen der Konfigurationssprache in der DNSControl-Dokumentation.

Ähnliche Artikel:

KI-Prompting (1) - Wasch mir meinen Mund mit Seife. Oder Benzin?

Clean, noisy oder mit Flüchen: Wie viel macht der Prompt bei Code Agents aus? Ein Praxistest mit über 700 Läufen zeigt, warum das Tooling oft wichtiger ist.

Website gehackt? Erste Schritte und Sofortmaßnahmen

Website gehackt? Erste Schritte und Sofortmaßnahmen

Website gehackt? Ruhe bewahren, Zugänge sichern, Spuren erhalten: Unser Leitfaden zeigt dir die ersten Schritte und woran du einen Hack erkennst.

Digitale Souveränität: Diese Dienste hostest du dank Containern einfach selbst

Container 101 (5): Digitale Souveränität: Diese Dienste hostest du dank Containern einfach selbst

Digitale Souveränität leicht gemacht: 3 Dienste wie Nextcloud dank Containern selbst hosten – Daten DSGVO-konform in Deutschland.

Shopware Container Deployment: Warum das Image read-only bleibt

Warum Caddy, Extension Store und Theme-Compiler beim Shopware-Deployment scheitern – und wie Du Dockerfile, Volumes und Object Storage richtig einsetzt.

Container auf dem Root-Server? Was managed Hosting dir abnimmt

Container 101 (4): Container auf dem Root Server? Was Managed Hosting dir abnimmt

Container auf dem Root Server oder managed Hosting? Wir zeigen dir, wo deine Zeit hinfließt und was mittwald dir bei Sicherheit, Updates und Backups abnimmt.

Kommentar hinzufügen