Dynamisches DNS fuer die Hetzner Cloud API - haelt A- und AAAA-Records auf der aktuellen oeffentlichen IP. Domain-agnostisch, per .env konfigurierbar.
  • Go 92.7%
  • Shell 3.5%
  • Makefile 2.5%
  • Dockerfile 1.3%
Find a file
nexus 20fd7ef3c7
All checks were successful
CI / test (push) Successful in 1m36s
CI / image (push) Successful in 57s
Merge pull request 'Gemountetes Secret ohne Variable finden, Compose-Block zusammenfassen' (#5) from feature/secret-by-convention into main
Reviewed-on: #5
2026-08-04 20:27:55 +02:00
.forgejo Rewrite the DDNS updater in Go and add IPv4 support 2026-08-04 13:37:15 +02:00
cmd/hetzner-ddns Rewrite the DDNS updater in Go and add IPv4 support 2026-08-04 13:37:15 +02:00
internal Fix the secret file location instead of configuring it 2026-08-04 20:21:51 +02:00
scripts Note that a domain bought at Hetzner needs no registrar change 2026-08-04 14:25:10 +02:00
.dockerignore Rewrite the DDNS updater in Go and add IPv4 support 2026-08-04 13:37:15 +02:00
.editorconfig Rewrite the DDNS updater in Go and add IPv4 support 2026-08-04 13:37:15 +02:00
.env.example Fix the secret file location instead of configuring it 2026-08-04 20:21:51 +02:00
.gitignore Add the repository skeleton 2026-08-04 13:05:27 +02:00
CHANGELOG.md Fix the secret file location instead of configuring it 2026-08-04 20:21:51 +02:00
CODEOWNERS Rewrite the DDNS updater in Go and add IPv4 support 2026-08-04 13:37:15 +02:00
CONTRIBUTING.md Rewrite the DDNS updater in Go and add IPv4 support 2026-08-04 13:37:15 +02:00
docker-compose.yml Fix the secret file location instead of configuring it 2026-08-04 20:21:51 +02:00
Dockerfile State that the published image is amd64, and build the right arch locally 2026-08-04 17:57:40 +02:00
go.mod Rewrite the DDNS updater in Go and add IPv4 support 2026-08-04 13:37:15 +02:00
LICENSE Add the repository skeleton 2026-08-04 13:05:27 +02:00
Makefile State that the published image is amd64, and build the right arch locally 2026-08-04 17:57:40 +02:00
README.md Fix the secret file location instead of configuring it 2026-08-04 20:21:51 +02:00
SECURITY.md Fix the secret file location instead of configuring it 2026-08-04 20:21:51 +02:00

hetzner-ddns

Dynamisches DNS für die Hetzner Cloud API. Das Tool ermittelt die aktuelle öffentliche IP-Adresse dieses Hosts und hält damit die A- und AAAA-Records beliebiger Hetzner-DNS-Zonen aktuell — für den Zonen-Apex, für Wildcards und für einzelne Namen.

Gedacht ist es für den klassischen Fall: ein NAS, ein Heimserver oder ein Raspberry Pi hinter einem Anschluss mit wechselnder IP, der trotzdem unter seinem eigenen Namen erreichbar bleiben soll.

  • Vollständig über eine .env konfigurierbar — keine Domain ist im Code verdrahtet.
  • IPv4 und IPv6: A-Records über externe Echo-Dienste (funktioniert hinter NAT), AAAA-Records wahlweise direkt vom Netzwerk-Interface.
  • Schreibt nur, wenn sich wirklich etwas geändert hat, und legt fehlende Records auf Wunsch selbst an.
  • Läuft unter Docker und Podman, auch rootless.
  • Container ohne Shell, ohne Paketmanager, ohne libc, als Nicht-Root — rund 8 MB.
  • Health-Endpunkt, sauberes Herunterfahren bei SIGTERM, Logs als JSON oder Text.

Cloud API, nicht DNS-Console. Hetzner betreibt zwei DNS-Schnittstellen. Dieses Tool nutzt die Cloud API (api.hetzner.cloud, Anmeldung per Authorization: Bearer). Ein Token der älteren DNS-Console (dns.hetzner.com, Header Auth-API-Token) funktioniert hier nicht und führt zu 401. Das ist der mit Abstand häufigste Einstiegsfehler.


Inhalt

  1. Quick Start
  2. Dateien und Image beziehen
  3. Voraussetzungen
  4. Schritt 1 — Vorbereitung bei Hetzner
  5. Schritt 2 — Konfiguration
  6. Schritt 3 — Probelauf
  7. Schritt 4 — Dauerbetrieb
  8. IPv4 hinter NAT, IPv6 am Interface
  9. Alle Konfigurationsvariablen
  10. Betrieb, Health und Logs
  11. Fehlersuche
  12. Sicherheit
  13. Aus dem Quelltext bauen

Quick Start

Für alle, die schon eine DNS-Zone bei Hetzner und ein Cloud-API-Token haben. Wenn nicht: Schritt 1 beschreibt beides.

# 1. Verzeichnis anlegen und die beiden Dateien holen
mkdir -p ~/hetzner-ddns && cd ~/hetzner-ddns
BASE=https://gbjo.mazurczak.cloud/nexus/hetzner-ddns/raw/branch/main
curl -fsSLO "$BASE/docker-compose.yml"
curl -fsSL  "$BASE/.env.example" -o .env

# 2. Token und Records eintragen
${EDITOR:-nano} .env

# 3. Probelauf — schreibt nichts, zeigt nur, was sich ändern würde
docker compose run --rm hetzner-ddns check

# 4. Wenn die Ausgabe stimmt: starten
docker compose up -d
docker compose logs -f

In .env sind nur zwei Zeilen wirklich nötig:

HETZNER_API_TOKEN=dein-cloud-api-token
DDNS_RECORDS=example.com:@,example.com:*

Zwei Wege für den Token. Für den Anfang ist HETZNER_API_TOKEN mit dem Wert direkt in der .env der kürzere, und für ein Gerät, auf das nur du Zugriff hast, ist er auch völlig in Ordnung.

Alternativ liest das Programm den Token aus einer Datei und hält ihn damit aus docker inspect heraus. Das lohnt sich, sobald mehrere Personen an den Host kommen. Dafür wird die Datei nach /run/secrets/hetzner_api_token gemountet — mehr ist nicht zu tun. Der Ort steht fest, es gibt keine Variable dafür, und in der .env bleibt der Token dann einfach leer. Die Einzelheiten stehen unter Geheimnisse aus einer Datei.

Wer IPv6 vom Interface lesen lässt (Voreinstellung), trägt zusätzlich den Namen des Interfaces auf dem Host ein — ip -6 addr zeigt ihn:

IPV6_INTERFACE=eno1

Mit Podman ist der Ablauf identisch, nur podman-compose statt docker compose.

Aktualisieren:

docker compose pull && docker compose up -d

Dateien und Image beziehen

Alles liegt öffentlich auf gbjo.mazurczak.cloud/nexus/hetzner-ddns — ein Konto wird zum Lesen und zum Pullen nicht gebraucht.

Nur die beiden Dateien für den Betrieb

Mehr als docker-compose.yml und .env braucht es nicht:

BASE=https://gbjo.mazurczak.cloud/nexus/hetzner-ddns/raw/branch/main
curl -fsSLO "$BASE/docker-compose.yml"
curl -fsSL  "$BASE/.env.example" -o .env

Das Muster der URL ist …/raw/branch/<branch>/<pfad>, es funktioniert also für jede Datei des Repos.

Ganzes Repo

git clone https://gbjo.mazurczak.cloud/nexus/hetzner-ddns.git

Oder als Archiv, ohne Git:

curl -fsSL https://gbjo.mazurczak.cloud/nexus/hetzner-ddns/archive/main.tar.gz \
  | tar xz

Nur das Image

docker pull gbjo.mazurczak.cloud/nexus/hetzner-ddns:latest

Es gibt bewusst nur :latest. Das Image wird bei jedem Merge nach main gebaut und enthält ausschließlich das Binary — kein Shell, kein Paketmanager.

Das veröffentlichte Image ist ein x86-64-Image (amd64). Auf einem gewöhnlichen PC, Server oder NAS mit Intel- oder AMD-Prozessor ist damit nichts weiter zu tun — docker pull genügt.

Nur auf ARM-Geräten — Raspberry Pi, ARM-basiertes NAS, Apple Silicon — passt das Image nicht, denn es enthält ein Go-Binary für genau eine Architektur. uname -m sagt, worauf du bist: x86_64 heißt amd64 und passt, aarch64 oder armv7l heißt ARM und passt nicht. In dem Fall einmal selbst bauen, das dauert etwa eine Minute:

git clone https://gbjo.mazurczak.cloud/nexus/hetzner-ddns.git
cd hetzner-ddns && make docker-build

Danach in docker-compose.yml statt der image:-Zeile den darunter auskommentierten build:-Block verwenden. Das Makefile erkennt Docker und Podman selbst.


Voraussetzungen

  • Eine Domain bei einem Registrar
  • Ein Hetzner-Account mit einem Cloud-Projekt
  • Docker oder Podman auf dem Gerät, das erreichbar bleiben soll

Schritt 1 — Vorbereitung bei Hetzner

1.1 DNS-Zone anlegen

  1. console.hetzner.com öffnen und ein Projekt auswählen — oder ein neues anlegen (siehe Kasten unten).
  2. Links DNS wählen und auf Zone hinzufügen klicken.
  3. Den Domainnamen ohne Punkt am Ende eintragen, also example.com.

Empfehlung: ein eigenes Projekt nur für DNS. Cloud-API-Tokens gelten immer für ein ganzes Projekt — einen Scope „nur DNS" gibt es nicht. Liegen im selben Projekt auch Server, Volumes oder Firewalls, dürfte dieses Token sie ebenfalls verändern oder löschen. Ein separates Projekt, das ausschließlich die DNS-Zonen enthält, begrenzt den Schaden eines geleakten Tokens auf genau diese Zonen. Das ist die einzige wirksame Einschränkung, die die API anbietet.

1.2 Nameserver beim Registrar eintragen

Bei Hetzner gekaufte Domains: dieser Schritt entfällt. Wurde die Domain direkt über Hetzner registriert, ist sie bereits an Hetzner delegiert — es gibt keinen fremden Registrar, bei dem etwas einzutragen wäre. Weiter mit 1.3.

Damit die Zone überhaupt greift, muss die Domain zu Hetzner delegiert werden. Beim Registrar der Domain die Nameserver eintragen, die die Hetzner Console bei dieser Zone anzeigt. Üblich ist dieser Satz:

hydrogen.ns.hetzner.com
oxygen.ns.hetzner.com
helium.ns.hetzner.de

Es gibt mehr als einen Hetzner-Nameserversatz. Je nachdem, woher die Zone stammt, kann sie stattdessen an ns1.your-server.de, ns.second-ns.com und ns3.second-ns.de delegiert sein. Das ist kein Fehler: Auch dieser Satz liefert eine DNS-Zone aus dem Cloud-Projekt ganz normal aus. Maßgeblich ist immer, was in der Console bei der Zone steht — nicht die Liste oben.

Eine Umstellung bei einem fremden Registrar kann bis zu 24 Stunden dauern. Ob die Delegierung steht, beantwortet eine Abfrage gegen einen externen Resolver:

dig +short NS example.com @1.1.1.1

Die Ausgabe muss zu den Nameservern passen, die die Console bei dieser Zone nennt — welcher der Sätze es ist, spielt keine Rolle. Erst dann beantwortet die Zone aus dem Cloud-Projekt Anfragen für diese Domain.

1.3 TTL niedrig setzen

Die TTL bestimmt, wie lange Resolver eine Antwort zwischenspeichern. Der Zonen-Standard liegt oft bei 86400 Sekunden — einem ganzen Tag. Damit wäre dynamisches DNS praktisch wirkungslos: Der Record wäre nach einem IP-Wechsel zwar sofort korrekt, aber die halbe Welt bekäme noch stundenlang die alte Adresse.

Sinnvoll sind 60 bis 300 Sekunden. hetzner-ddns setzt die TTL beim Anlegen eines Records selbst (DDNS_TTL) und korrigiert sie auch später, wenn sie abweicht — von Hand nachziehen muss man also nichts.

1.4 Records anlegen (optional)

Die Records können vorab in der Console angelegt werden. Muss man aber nicht: mit DDNS_CREATE_MISSING=true (Standard) legt hetzner-ddns fehlende RRsets beim ersten Durchlauf selbst an, mit der konfigurierten TTL.

1.5 API-Token erzeugen

  1. In der Console das Projekt öffnen, in dem die Zone liegt.
  2. Security → API Tokens → Token generieren.
  3. Berechtigung Read & Write wählen. Nur-Lesen genügt nicht.
  4. Den Wert sofort kopieren — er wird nur ein einziges Mal angezeigt.

Schritt 2 — Konfiguration

Ein Verzeichnis anlegen und darin eine Datei .env erstellen. Das Minimum sind zwei Zeilen:

HETZNER_API_TOKEN=dein-cloud-api-token
DDNS_RECORDS=example.com:@,example.com:*

Die vollständige Vorlage mit allen Optionen und Erklärungen liegt in .env.example.

Die Record-Syntax

DDNS_RECORDS ist eine kommagetrennte Liste. Jeder Eintrag hat die Form:

zone:name[:typ[+typ]]
Teil Bedeutung
zone Der Zonenname, wie er in der Hetzner Console steht — example.com
name Der Name relativ zur Zone. @ ist der Apex, * der Wildcard
typ Optional. Überschreibt DDNS_TYPES für genau diesen Eintrag

Beispiele:

# Apex und Wildcard, mit den Typen aus DDNS_TYPES
DDNS_RECORDS=example.com:@,example.com:*

# nas.example.com — nur der Name, nicht der volle Domainname
DDNS_RECORDS=example.com:nas

# Nur AAAA für einen Eintrag, beide Typen für den anderen
DDNS_RECORDS=example.com:@,example.com:v6only:AAAA

# Beide Typen ausdrücklich für einen einzelnen Eintrag
DDNS_RECORDS=example.com:vpn:A+AAAA

# Mehrere Zonen — sie müssen im selben Projekt wie das Token liegen
DDNS_RECORDS=example.com:@,example.com:*,example.org:@

Häufiger Stolperstein: Der Name ist relativ. example.com:nas.example.com würde nas.example.com.example.com erzeugen. Das Tool weist das beim Start ab und nennt die richtige Schreibweise.

Geheimnisse aus einer Datei

Statt HETZNER_API_TOKEN in der .env kann der Token als Datei bereitgestellt werden. Der Ort dafür steht fest:

/run/secrets/hetzner_api_token

Dieser Pfad ist nicht konfigurierbar, und das ist Absicht. Es gibt keine Variable dafür — liegt dort eine Datei, wird sie benutzt. Damit ist der Mount die gesamte Konfiguration; in der .env ist nichts einzutragen.

Zu beachten bleibt nur eines: Der Prozess läuft nicht als Root. Eine Datei, die root mit 0600 gehört, ist für ihn nicht lesbar.

Der Mount

In docker-compose.yml ist die betreffende Zeile auskommentiert und muss zunächst aktiviert werden — das # am Zeilenanfang entfernen:

    volumes:
      - /etc/localtime:/etc/localtime:ro
      - ./hetzner_api_token:/run/secrets/hetzner_api_token:ro

Eine Mount-Zeile besteht aus zwei durch : getrennten Pfaden:

  ./hetzner_api_token  :  /run/secrets/hetzner_api_token  :ro
  └── wo die Datei        └── wo sie im Container            └── nur lesbar
      auf dem Host liegt      erscheint

Die linke Seite richtet sich danach, wo deine Token-Datei tatsächlich liegt:

Ablageort der Datei Linke Seite der Mount-Zeile
Neben der docker-compose.yml ./hetzner_api_token
In einem Unterverzeichnis daneben ./secrets/hetzner_api_token
Irgendwo sonst auf dem Host Der vollständige Pfad, z.B. /srv/container/hetzner-ddns/hetzner_api_token

Die rechte Seite bleibt, wie sie ist. Sie ist der Ort, an dem das Programm nachsieht, und sie ist nicht konfigurierbar. Damit ist die Sache erledigt: in der .env ist nichts einzutragen — kein Pfad, keine Variable.

Der Ort ist derselbe, den Docker und Podman auch für ihre eigenen secrets:-Blöcke benutzen. Ein solcher Block funktioniert deshalb ebenfalls ohne weitere Konfiguration:

    secrets:
      - hetzner_api_token

secrets:
  hetzner_api_token:
    file: ./hetzner_api_token

Die Rechte — der bequeme Weg

Der Container muss die Datei lesen können. Es liegt nahe, sie ihm dafür zu übereignen — aber dann gehört sie einem Benutzer, der auf dem Host gar nicht existiert, und man kommt selbst nicht mehr bequem daran.

Umgekehrt ist es einfacher: den Container unter der UID laufen lassen, der die Datei ohnehin gehört. Das Image legt sich auf keine bestimmte UID fest — es schreibt nichts, schlägt keinen Benutzer in /etc/passwd nach, und das Binary ist für alle ausführbar.

# docker-compose.yml
    user: "1000:1000"        # deine eigene uid:gid, siehe `id -u` und `id -g`
chmod 600 hetzner_api_token   # gehoert weiterhin dir

Damit bleibt die Datei dein Eigentum, du bearbeitest sie wie jede andere, und es braucht weder chown noch podman unshare. Funktioniert unter Docker wie unter Podman, rootful wie rootless.

Warum 600 und nicht 400? 400 nimmt dem Eigentümer das Schreibrecht, ohne dass es etwas gewinnt: Gegen andere Benutzer des Hosts schützt bereits 600, und die Datei ist ohnehin nur so gut geschützt wie das Verzeichnis, in dem sie liegt. 400 bringt nur dann etwas, wenn ein versehentliches Überschreiben durch den Eigentümer selbst das Problem ist — und dann ist chmod einen Moment lang zurückzusetzen die Lösung, kein Dauerzustand.

Die folgenden Varianten braucht nur, wer die Vorgabe-UID des Images (65532) beibehalten will — etwa weil eine Richtlinie eine feste, nicht-menschliche UID verlangt. Für einen selbst verwalteten NAS ist der Weg oben der richtige.

Bei UID 65532 bleiben, ohne User-Namespace

Das ist der Standardfall bei Docker. Die UID im Container ist dieselbe wie auf dem Host:

chown 65532:65532 hetzner_api_token
chmod 400 hetzner_api_token

Die Datei gehört danach niemandem auf dem Host. Zum Bearbeiten braucht es sudo — oder man gibt der eigenen Gruppe Schreibrecht:

chown 65532:$(id -g) hetzner_api_token
chmod 460 hetzner_api_token

Bei UID 65532 bleiben, mit User-Namespace (rootless Podman)

Hier greift die Abbildung aus /etc/subuid: Container-UID 65532 entspricht auf dem Host einer ganz anderen UID. Ein chown 65532 auf dem Host setzt deshalb die falsche — die Zuordnung stellt podman unshare her, das in denselben Namespace wechselt:

podman unshare chown 65532:0 hetzner_api_token
podman unshare chmod 460 hetzner_api_token

Die 0 ist Absicht. GIDs werden genauso abgebildet wie UIDs, und in der üblichen Konfiguration entspricht Container-GID 0 der Gruppe deines Host-Benutzers. Mit 460 liest der Container-Benutzer die Datei (4), und du kannst sie auf dem Host weiterhin lesen und bearbeiten (6), ohne jedes Mal podman unshare zu bemühen. Wer sie nur lesen können muss, nimmt 440.

Bei UID 65532 bleiben, mit User-Namespace unter Docker

Dieselbe Abbildung gibt es auch bei Docker — aber nur bei rootless Docker oder wenn der Daemon mit --userns-remap läuft. Ein Gegenstück zu podman unshare gibt es dort allerdings nicht.

Der portable Ausweg ist, das chown aus einem Wegwerf-Container heraus zu machen; der steckt bereits im richtigen Namespace:

docker run --rm -v "$PWD/hetzner_api_token:/t" --user 0 \
  docker.io/library/alpine chown 65532:65532 /t

Umständlich genug, dass in diesem Fall die user:-Zeile oben die bessere Wahl ist.

In dieser Datei steht nur der Token selbst — kein KEY=VALUE, kein Kommentar, keine zweite Zeile:

# richtig
printf '%s' 'dein-cloud-api-token' > secrets/hetzner_api_token
chmod 600 secrets/hetzner_api_token
# falsch — das Präfix würde Teil des Tokens und ergäbe ein 401
HETZNER_API_TOKEN=dein-cloud-api-token

Ein abschließender Zeilenumbruch stört nicht, er wird abgeschnitten. Beide Fehlerfälle erkennt das Programm beim Start und benennt sie.

Der Weg über die Datei ist die bessere Variante, sobald mehr als eine Person Zugriff auf den Host hat: Umgebungsvariablen sind über docker inspect für jeden lesbar, der an den Docker-Socket kommt — der Inhalt einer Secret-Datei nicht. Ist beides vorhanden — die Variable gesetzt und eine Datei gemountet —, gewinnt die Variable.


Schritt 3 — Probelauf

Vor dem ersten scharfen Lauf zeigt das Unterkommando check, was passieren würde, ohne irgendetwas zu schreiben:

docker run --rm --env-file .env --network host \
  gbjo.mazurczak.cloud/nexus/hetzner-ddns:latest check

Mit Podman identisch, nur podman run.

Die Ausgabe nennt die ermittelten Adressen und für jeden Record, ob er bereits stimmt, geändert oder angelegt würde:

level=INFO msg="hetzner-ddns starting" mode="check (nothing will be written)" records=4 ttl=300
level=INFO msg="managing record" record="example.com A"
level=INFO msg="managing record" record="example.com AAAA"
level=INFO msg="current address" type=A address=203.0.114.7 source="HTTP echo (…)"
level=INFO msg="current address" type=AAAA address=2a01:4f8:1:2::1 source="interface \"eth0\""
level=INFO msg="would update record" record="example.com A" from=[203.0.114.1] to=203.0.114.7
level=INFO msg="would create record" record="nas.example.com AAAA" value=2a01:4f8:1:2::1 ttl=300
level=INFO msg="cycle finished" checked=4 changed=1 created=1 failed=0 skipped=0

Prüfenswert ist hier vor allem, ob die gemeldete IPv4 mit der übereinstimmt, die auch curl -s https://api.ipify.org liefert — und ob die IPv6 die des Geräts ist und keine private Adresse.


Schritt 4 — Dauerbetrieb

Mit docker run

docker run -d --name hetzner-ddns --restart unless-stopped \
  --network host \
  --read-only --cap-drop ALL --security-opt no-new-privileges \
  --env-file .env \
  gbjo.mazurczak.cloud/nexus/hetzner-ddns:latest

Logs mitlesen mit docker logs -f hetzner-ddns.

Mit Compose

Die mitgelieferte docker-compose.yml läuft unverändert unter beiden Engines:

docker compose up -d      # oder: podman-compose up -d
docker compose logs -f

Auf eine neuere Version wechseln:

docker compose pull && docker compose up -d

Podman-Besonderheiten

Alles Nötige ist bereits im Image und in der Compose-Datei berücksichtigt, aber es ist gut zu wissen, warum:

  • Der Healthcheck steht doppelt. Podman schreibt Images im OCI-Format, und das kennt kein HEALTHCHECK-Feld — der Check aus dem Image geht dabei verloren. docker-compose.yml deklariert ihn deshalb noch einmal. Wer das Image lokal mit Podman baut, braucht podman build --format docker (make docker-build erledigt das automatisch).
  • podman-compose ignoriert COMPOSE_PROFILES. Diese Compose-Datei nutzt keine Profile, insofern spielt es hier keine Rolle.
  • Rootless funktioniert. Der Container schreibt nichts auf die Platte und braucht kein beschreibbares Volume, deshalb entfällt die sonst übliche Reibung mit Benutzer-Namespaces und Volume-Rechten. network_mode: host reicht auch rootless aus, um die Adressen des Hosts zu lesen.
  • Healthcheck von Hand prüfen: podman healthcheck run hetzner-ddns.

Ohne Dauerläufer: cron oder systemd-Timer

Mit DDNS_ONESHOT=true läuft das Tool einen Durchlauf und beendet sich, mit Exit-Code ungleich null, falls etwas fehlgeschlagen ist:

*/5 * * * * docker run --rm --network host --env-file /etc/hetzner-ddns/.env \
  gbjo.mazurczak.cloud/nexus/hetzner-ddns:latest

In diesem Modus gilt die Untergrenze von 60 Sekunden für DDNS_INTERVAL nicht, weil der Takt von außen kommt.


IPv4 hinter NAT, IPv6 am Interface

Die beiden Adressfamilien werden unterschiedlich ermittelt, und das hat einen Grund.

IPv4 (IPV4_SOURCE=http, Standard). Hinter NAT — also an praktisch jedem Privatanschluss — trägt das Gerät nur eine private Adresse wie 192.168.178.11. Die öffentliche IPv4 kennt allein der Router. Deshalb wird sie bei externen Echo-Diensten erfragt, die zurückmelden, unter welcher Adresse die Anfrage bei ihnen ankam. Die Anbieter sind über IPV4_HTTP_ENDPOINTS frei austauschbar; sie werden der Reihe nach probiert, der erste brauchbare Treffer gewinnt.

Wer einen Root-Server betreibt, der seine öffentliche IPv4 selbst trägt, kann auf IPV4_SOURCE=interface umstellen.

IPv6 (IPV6_SOURCE=interface, Standard). Bei IPv6 gibt es kein NAT: jedes Gerät hat seine eigene öffentliche Adresse. Ein Echo-Dienst würde möglicherweise die Adresse des Routers melden, nicht die des NAS. Deshalb wird hier direkt das Interface gelesen.

Dabei werden gezielt aussortiert:

  • Temporäre Adressen (RFC 4941, Privacy Extensions). Sie wechseln alle paar Stunden — ein Record darauf wäre sofort wieder falsch.
  • Veraltete und vorläufige Adressen (deprecated, tentative).
  • ULA-Adressen aus fc00::/7 und Link-Local aus fe80::/10. Beide werden im Internet nicht geroutet.

Bevorzugt wird die als permanent markierte globale Adresse.

network_mode: host wird nur für IPV6_SOURCE=interface gebraucht. Ohne den Netzwerk-Namespace des Hosts sieht der Container nur seine eigenen Interfaces. Wer das vermeiden will, stellt beide Familien auf http um und kann dann ohne Host-Networking auskommen — vorausgesetzt, der Container hat selbst IPv6-Konnektivität.

IPV6_INTERFACE ist der Name auf dem Host, nicht im Container. Welche es gibt, zeigt ip -6 addr; typisch sind eno1, enp3s0 oder eth0.

Adressen, die nie veröffentlicht werden. Unabhängig von der Quelle prüft das Tool jede ermittelte Adresse und verwirft alles, was im Internet nicht erreichbar ist: RFC-1918-Bereiche, Carrier-Grade-NAT (100.64.0.0/10), Loopback, Link-Local, Multicast, Dokumentationsbereiche, ULA. Damit landet keine 192.168.x.x versehentlich als A-Record — ein Fehler, der sonst unbemerkt bliebe, weil im eigenen LAN alles weiter funktioniert.


Alle Konfigurationsvariablen

Pflicht sind nur die ersten beiden.

Variable Vorgabe Bedeutung
HETZNER_API_TOKEN Pflicht. Cloud-API-Token mit Read & Write
DDNS_RECORDS Pflicht. Liste zone:name[:typ], siehe oben
HETZNER_API_URL https://api.hetzner.cloud/v1 Muss https:// sein
HETZNER_API_TIMEOUT 10s Zeitlimit je Anfrage
HETZNER_API_RETRIES 3 Wiederholungen bei 5xx, 429, Netzwerkfehlern
DDNS_TYPES A,AAAA Standardtypen für Einträge ohne drittes Feld
DDNS_TTL 300 TTL in Sekunden, Minimum 60
DDNS_RECORD_COMMENT managed-by-hetzner-ddns Kommentar am geschriebenen Record
DDNS_CREATE_MISSING true Fehlende RRsets selbst anlegen
IPV4_ENABLED true A-Records pflegen
IPV4_SOURCE http http, interface oder none
IPV4_HTTP_ENDPOINTS ipify, icanhazip, checkip Fallback-Kette, https:// erforderlich
IPV4_INTERFACE Nur bei IPV4_SOURCE=interface
IPV6_ENABLED true AAAA-Records pflegen
IPV6_SOURCE interface interface, http oder none
IPV6_INTERFACE eth0 Interface auf dem Host
IPV6_HTTP_ENDPOINTS ipify, icanhazip Nur bei IPV6_SOURCE=http
DDNS_INTERVAL 300s Abstand zwischen Durchläufen, Minimum 60s
DDNS_ONESHOT false Einmal laufen und beenden
DDNS_DRY_RUN false Nichts schreiben, nur protokollieren
LOG_LEVEL info debug, info, warn, error
LOG_FORMAT json json oder text
HEALTH_ADDR :8080 Leer schaltet den Health-Server ab
HEALTH_PORT 8080 Nur für das Port-Mapping in Compose
TZ Zeitzone für die Zeitstempel im Log

Fehlerhafte Werte werden beim Start abgewiesen, und zwar alle auf einmal — eine kaputte Konfiguration muss man nicht Neustart für Neustart reparieren.


Betrieb, Health und Logs

Wann geschrieben wird

Jeder Durchlauf ermittelt die Adressen einmal pro Familie und vergleicht sie dann für jeden Record mit dem tatsächlichen Zustand in der Zone. Geschrieben wird nur bei einer Abweichung. Ein bestätigter Wert wird für die Laufzeit des Prozesses gemerkt, sodass ein unveränderter Record in späteren Durchläufen gar keine Anfrage mehr auslöst.

Der Prozess hält keinen Zustand auf der Platte. Nach einem Neustart liest er den Ist-Zustand einmal aus der API und schreibt dann ebenfalls nichts, wenn alles stimmt. Deshalb braucht der Container kein Volume und kann read_only laufen.

Schlägt eine Familie fehl — etwa weil IPv6 gerade weg ist — bleibt die andere davon unberührt: Die A-Records werden weiter gepflegt.

Health-Endpunkte

Pfad Bedeutung
GET /healthz Der Prozess läuft. Fragt bewusst nicht den Update-Zustand ab — ein Neustart behebt keinen API-Ausfall
GET /readyz 200, solange ein Durchlauf kürzlich erfolgreich war; sonst 503 mit einer Begründung im JSON

Ein einzelner fehlgeschlagener Durchlauf macht den Container nicht sofort ungesund — die Records sind ja weiterhin richtig. Erst wenn längere Zeit kein Durchlauf mehr geklappt hat, wird /readyz rot.

Das Image bringt keine Shell und kein curl mit, deshalb dient das Binary selbst als Healthcheck:

docker exec hetzner-ddns hetzner-ddns healthcheck    # Exit 0 = bereit

Weitere Unterkommandos

hetzner-ddns run           # Vorgabe: dauerhaft aktualisieren
hetzner-ddns check         # anzeigen, was sich ändern würde
hetzner-ddns healthcheck   # Bereitschaft einer laufenden Instanz prüfen
hetzner-ddns version       # Version und Commit ausgeben

SIGTERM beendet den Prozess sofort, ohne das laufende Intervall abzuwarten — docker stop dauert also keine zehn Sekunden.


Fehlersuche

LOG_LEVEL=debug protokolliert jede API-Anfrage und jede IP-Ermittlung. Das Token wird dabei in jedem Fall unkenntlich gemacht, auch wenn es in einer Fehlermeldung stecken sollte.

Symptom Ursache und Abhilfe
HTTP 401 beim Start Falscher Token-Typ. Dieses Tool braucht ein Cloud-API-Token von console.hetzner.com, kein Token der alten DNS-Console. Neu erzeugen unter Security → API Tokens
no API token, obwohl die Token-Datei existiert Sie ist nicht nach /run/secrets/hetzner_api_token gemountet. Die rechte Seite der Mount-Zeile muss exakt dieser Pfad sein — siehe Geheimnisse aus einer Datei
/run/secrets/hetzner_api_token is not readable Die Datei gehört root mit 0600, der Prozess läuft als UID 65532. Entweder user: auf den Eigentümer setzen oder die Datei ihm zugänglich machen
HTTP 403 Das Token hat nur Leserechte, oder die Zone liegt in einem anderen Projekt als das Token
HTTP 404 und die Zone existiert Das Token gehört zu einem anderen Projekt. Cloud-Tokens sind projektgebunden
record does not exist DDNS_CREATE_MISSING=false und der Record fehlt. Entweder in der Console anlegen oder die Option einschalten
HTTP 429 Rate-Limit erreicht (3600 Anfragen pro Stunde und Projekt). DDNS_INTERVAL erhöhen oder die Zahl der Records verringern
no usable address found für IPv6, Interface nicht gefunden Falscher Interface-Name, oder der Container läuft ohne network_mode: host. ip -6 addr auf dem Host zeigt die richtigen Namen
has no publishable global IPv6 Auf dem Interface liegt nur eine temporäre, veraltete oder ULA-Adresse. Prüfen, ob der Anschluss überhaupt eine feste globale IPv6 bekommt
interface … has no public IPv4 Das Gerät hängt hinter NAT. IPV4_SOURCE=http setzen (Standard)
A-Record zeigt auf 192.168.x.x Kann mit diesem Tool nicht passieren — private Adressen werden verworfen. Steht so ein Wert in der Zone, stammt er aus einer anderen Quelle
dig liefert eine andere Adresse als erwartet Der eigene Resolver schreibt um. Pi-hole, FritzBox oder ein Split-Horizon-Setup beantworten Anfragen für die eigene Domain oft mit der LAN-Adresse. Immer gegen einen externen Resolver prüfen: dig +short A example.com @1.1.1.1
Änderung wirkt nicht Die alte TTL läuft noch. Nach der ersten Umstellung auf 300s einmal so lange warten, wie die vorherige TTL war
Zone antwortet gar nicht Die Delegierung fehlt oder ist noch nicht durch. dig +short NS example.com @1.1.1.1 muss die Hetzner-Nameserver zeigen

Sicherheit

Das Laufzeit-Image basiert auf gcr.io/distroless/static:nonroot und enthält außer dem Binary und dem CA-Bundle nichts: keine Shell, keinen Paketmanager, keine libc. Es läuft als Nicht-Root (UID 65532) und kommt mit read_only, cap_drop: ALL und no-new-privileges aus. Das Programm selbst hat keine Abhängigkeiten außerhalb der Go-Standardbibliothek.

Empfehlungen für den Betrieb:

  • Eigenes Projekt für die DNS-Zonen. Cloud-Tokens gelten projektweit; das ist die einzige wirksame Begrenzung des Schadensradius.
  • Ein eigenes Token für dieses Tool, nicht dasselbe wie für eine ACME-DNS-Challenge oder ein anderes Werkzeug. Getrennte Tokens lassen sich einzeln zurückziehen.
  • Den Token als Datei mounten statt ihn in die .env zu schreiben, sobald mehrere Personen Zugriff auf den Host haben.
  • Wer die IP nicht an die voreingestellten Echo-Dienste geben möchte, trägt unter IPV4_HTTP_ENDPOINTS eigene ein — etwa einen selbst betriebenen.

Sicherheitslücken bitte nach SECURITY.md melden.


Aus dem Quelltext bauen

Vorausgesetzt wird Go in der Version aus go.mod.

make build          # Binary nach bin/
make test           # Tests
make test-race      # Tests mit Race-Detector
make lint           # gofmt, go vet, golangci-lint
make check          # Probelauf gegen die lokale .env
make docker-build   # Image bauen (erkennt Docker oder Podman selbst)
make help           # alle Ziele auflisten

Beiträge sind willkommen — siehe CONTRIBUTING.md.


Lizenz

MIT — siehe LICENSE.