Feng-IT

Schluss mit No-IP: DynDNS mit eigener Domain selbst bauen

Warum kostenlose DynDNS-Dienste unterwegs versagen – und wie man sie mit einer eigenen Domain, vierzig Zeilen Bash und einem systemd-Timer ersetzt.

Der Auslöser

Ein VPN-Tunnel, der plötzlich stumm bleibt. Am Handy zählt “gesendet” fröhlich hoch, “empfangen” steht auf 0 Byte. Der Server läuft, die Firewall ist offen, die Portweiterleitung stimmt.

Die Ursache lag woanders: Der DynDNS-Hostname *.ddns.net wurde vom WLAN, in dem ich gerade hing, per DNS-Filter blockiert. Dynamic-DNS-Domains stehen bei fast jedem Filterdienst in einer eigenen Sperrkategorie – NextDNS, AdGuard, Firmennetze, Hotel-WLANs, Jugendschutz-Resolver. Für den Filter sieht so ein Hostname aus wie ein Command-and-Control-Server.

Das war der Punkt, an dem ich die ganze Konstruktion über Bord geworfen habe.

Was gegen kostenlose DynDNS-Dienste spricht

Filterlisten. *.ddns.net, *.duckdns.org, *.myddns.me und Konsorten sind pauschal gesperrt, sobald ein Netz irgendeine Form von DNS-Filterung betreibt. Ausgerechnet unterwegs, wo man den Fernzugriff braucht.

Bestätigungspflicht. No-IP verlangt im Free-Tarif alle 30 Tage eine manuelle Bestätigung per Mail-Link. Verpasst man sie, wird der Hostname deaktiviert. Das passiert zuverlässig dann, wenn man im Urlaub ist.

Rate-Limits. Ein Updater, der alle 30 Minuten anfragt, kassiert bei manchen Anbietern irgendwann ein 403 Updateintervall overcommited. Ab da wird nichts mehr aktualisiert – still und ohne Alarm.

Fremdabhängigkeit. Der Dienst kann Preise ändern, Tarife streichen oder verschwinden. Meine Erreichbarkeit hängt dann an jemand anderem.

Client-Caching. Android und die WireGuard-App lösen den Endpoint nur beim Start des Tunnels auf. Wechselt die IP, schickt der Client bis zum Neustart stur an die tote Adresse. Mit kurzer TTL und eigener Kontrolle lässt sich das entschärfen.

Was man braucht

Erstaunlich wenig:

  1. Eine eigene Domain. Kostet ein paar Euro im Jahr.
  2. Einen DNS-Hoster mit API. Hetzner, Cloudflare, deSEC, netcup – die meisten bieten das.
  3. Einen Rechner, der immer läuft. Ein LXC-Container, ein Raspberry Pi, die Firewall. Hauptsache im Netz, dessen IP überwacht werden soll.
  4. curl. Das war’s an Abhängigkeiten.

Kein zusätzlicher Dienst, kein Account bei Dritten, kein Abo.

Das Konzept: ein einziger Anker

Der zentrale Kniff ist, nicht jeden Hostnamen einzeln zu aktualisieren. Stattdessen:

A      dyn.example.at        <dynamische IP>   TTL 60    ← einziger Record, der sich ändert
CNAME  vpn.example.at        dyn.example.at.   TTL 300
CNAME  cloud.example.at      dyn.example.at.   TTL 300
CNAME  wiki.example.at       dyn.example.at.   TTL 300
CNAME  media.example.at      dyn.example.at.   TTL 300

Das Skript kennt genau eine Record-ID. Neue Dienste bekommen einfach einen weiteren CNAME – ohne dass die Automatik angefasst werden muss. Und wenn doch mal etwas schiefgeht, gibt es nur eine Stelle zum Nachsehen.

Die TTL des A-Records gehört auf 60 Sekunden. Die CNAMEs dürfen ruhig länger stehen, sie ändern sich ja nie.

Umsetzung mit der Hetzner Cloud API

Ein Hinweis vorweg: Hetzner hat die alte DNS-API unter dns.hetzner.com im Mai 2026 abgeschaltet. Alte Anleitungen und ältere ddclient-Versionen zielen ins Leere. Die aktuelle API läuft über api.hetzner.cloud und arbeitet mit sogenannten RRSets.

Vorbereitung

In der Hetzner Console den A-Record dyn mit TTL 60 anlegen – die API kann bestehende Records ändern, aber keine neuen erzeugen.

Dann unter Projekt → Security → API-Tokens ein Token mit Lese- und Schreibrechten erstellen. Der Wert wird nur einmal angezeigt.

Zone-ID ermitteln:

curl -s -H "Authorization: Bearer $API_TOKEN" \
  "https://api.hetzner.cloud/v1/zones" | jq -r '.zones[] | "\(.name) \(.id)"'

Das Skript

#!/usr/bin/env bash
set -euo pipefail

API_TOKEN="<TOKEN>"
ZONE="<ZONE-ID>"
RRNAME="dyn"
RRTYPE="A"
CACHE="/opt/dyndns/.last_ip"
LOG="/var/log/hetzner-ddns.log"

IP=$(curl -4 -s --max-time 10 https://ifconfig.me || true)
[[ "$IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || {
    echo "$(date '+%F %T') | keine gueltige IP" >>"$LOG"; exit 1; }

[[ -f "$CACHE" && "$(cat "$CACHE")" == "$IP" ]] && exit 0

RESP=$(curl -s -w '\n%{http_code}' -X POST \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d "{\"records\":[{\"value\":\"${IP}\",\"comment\":\"ddns\"}]}" \
  "https://api.hetzner.cloud/v1/zones/${ZONE}/rrsets/${RRNAME}/${RRTYPE}/actions/set_records")

CODE=$(tail -n1 <<<"$RESP")
if [[ "$CODE" =~ ^2 ]]; then
  echo "$IP" > "$CACHE"
  echo "$(date '+%F %T') | OK ${RRNAME} -> ${IP}" >>"$LOG"
  logger -t hetzner-ddns "A-Record ${RRNAME} auf ${IP} gesetzt"
else
  echo "$(date '+%F %T') | FEHLER HTTP ${CODE}: $(head -n-1 <<<"$RESP")" >>"$LOG"
  exit 1
fi

Vier Designentscheidungen, die den Unterschied machen:

  • Cache-Datei: Bei unveränderter IP passiert nichts. Keine unnötigen API-Calls, keine Rate-Limit-Probleme.
  • Regex-Prüfung: Fängt leere oder kaputte Antworten des IP-Dienstes ab. Sonst landet irgendwann Müll im DNS.
  • HTTP-Code auswerten: Ein curl ohne Prüfung meldet Erfolg, auch wenn die API einen Fehler zurückgibt.
  • Stiller Exit bei Erfolg ohne Änderung: Das Log bleibt lesbar.

Rechte setzen, das Token steht im Klartext drin:

chmod 700 /opt/dyndns/hetzner-ddns.sh

Systemd-Timer statt Cron

# /etc/systemd/system/hetzner-ddns.service
[Unit]
Description=Hetzner DynDNS Update
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/dyndns/hetzner-ddns.sh
# /etc/systemd/system/hetzner-ddns.timer
[Unit]
Description=Hetzner DynDNS alle 5 Minuten

[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Persistent=true

[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now hetzner-ddns.timer
systemctl list-timers hetzner-ddns.timer

Warum Timer statt Cron? Persistent=true holt verpasste Läufe nach einem Neustart nach, OnBootSec sorgt für einen Lauf direkt nach dem Hochfahren, und der Status ist über systemctl und journalctl sauber einsehbar.

Der Teil, den die meisten Anleitungen weglassen: Überwachung

Ein Updater, der still ausfällt, ist schlimmer als keiner – man merkt es erst, wenn man von unterwegs nicht mehr reinkommt.

Ein kleines Watchdog-Skript prüft, ob die überwachten Namen tatsächlich auf die aktuelle WAN-IP zeigen, und schickt bei Abweichung eine Mail:

WAN=$(curl -4 -s https://ifconfig.me)
for h in dyn.example.at vpn.example.at; do
    IP=$(dig +short "$h" @9.9.9.9 | tail -1)
    [[ "$IP" == "$WAN" ]] || echo "ABWEICHUNG: $h -> $IP (erwartet $WAN)"
done

Zwei Details sind wichtig:

Externe Resolver verwenden, nicht den lokalen Pi-hole. Sonst prüft man die eigene Sicht statt der von außen.

Auch den Updater-Dienst prüfen – ein gestoppter Timer fällt sonst erst auf, wenn die IP wechselt.

Genau dieser Watchdog hat bei mir später den Ausfall eines verbliebenen No-IP-Namens gemeldet, während die eigene Domain sauber weiterlief.

Migration ohne Ausfall

Wer bestehende Dienste umzieht, sollte gestaffelt vorgehen:

  1. dyn-Record anlegen, Updater einrichten, beide Systeme parallel laufen lassen
  2. Ein bis zwei Tage beobachten – idealerweise über eine Zwangstrennung hinweg
  3. CNAMEs einzeln umhängen, nach jedem Schritt den Dienst testen
  4. Clients umstellen (VPN, Cloud-Apps, CalDAV, Chat)
  5. Erst dann die alten Updater abschalten

Schritt 4 wird gern unterschätzt. Ein Reverse Proxy arbeitet mit Host-Headern und merkt von der DNS-Umstellung gar nichts. Clients dagegen haben den alten Namen fest eingetragen – und manche, etwa DAVx5, erlauben keine nachträgliche Änderung der Basis-URL, sondern verlangen ein neues Konto.

Fallstricke aus der Praxis

Anwendungen mit Hostnamen-Prüfung. Nextcloud hat trusted_domains und overwritehost, Paperless-ngx hat PAPERLESS_ALLOWED_HOSTS. Wer nur den DNS-Eintrag ändert, bekommt eine Fehlerseite. Bei Nextcloud sorgt ein gesetztes overwritehost zusätzlich dafür, dass jeder Aufruf auf den alten Namen umgeleitet wird.

Apache und VirtualHosts. Fehlt der ServerAlias für den neuen Namen, landet die Anfrage im Default-VHost – und man sieht die Ubuntu-Standardseite statt der Anwendung.

Kein AAAA-Record anlegen, solange kein IPv6-Dienst dahinter lauscht. Clients mit IPv6 bevorzugen sonst die AAAA und laufen ins Leere.

Subnetz-Kollisionen. Wer sein Heimnetz auf 192.168.1.0/24 betreibt, kommt per VPN aus jedem Hotel-WLAN mit demselben Bereich nicht an die eigenen Geräte – die lokale Route gewinnt immer. Ein unüblicher Bereich wie 192.168.47.0/24 erspart viel Fehlersuche.

Erst prüfen, dann konfigurieren. Ich habe einmal eine halbe Stunde an der falschen Instanz geschraubt, weil ein zweiter Container dieselbe Rolle hatte und ich die IP nicht gegengeprüft hatte. Ein arp-Eintrag oder ein Blick in die DHCP-Leases hätte das sofort geklärt.

Alternativen zu Hetzner

Das Prinzip funktioniert mit jedem Hoster, der eine Schreib-API bietet:

Cloudflare – kostenlos, gute API, ddclient unterstützt es nativ. Wichtig: Der Proxy-Status muss auf “DNS only” stehen (graue Wolke), sonst funktioniert kein UDP und damit kein WireGuard.

deSEC.io – gemeinnützig, Open Source, DNSSEC standardmäßig. Man kann eine dedyn.io-Domain nehmen oder eine Subdomain der eigenen Zone dorthin delegieren.

ddclient – wer nicht selbst skripten will: unterstützt Cloudflare, deSEC, Hetzner (alte API!), Gandi, Namecheap und viele mehr. Vor dem Einsatz prüfen, ob der Anbieter noch die API-Version verwendet, die das installierte ddclient kennt.

Fazit

Ein DynDNS-Dienst ist letztlich nichts weiter als “Domain + API + Updater”. Zwei davon hat man meist schon, das dritte sind vierzig Zeilen Bash.

Was man dafür bekommt: keine Filterprobleme unterwegs, keine Bestätigungsfristen, kontrollierbare TTL, keine Rate-Limits – und eine Stelle weniger, an der jemand anders über die eigene Erreichbarkeit entscheidet.

Der Aufwand liegt bei einem gemütlichen Nachmittag. Die Migration bestehender Dienste dauert länger als der Aufbau selbst, aber sie lässt sich ohne Ausfall gestaffelt erledigen.

Passt das zu Ihrer Situation?

Ein kurzes Gespräch klärt meist schneller als jede Beschreibung, ob und wie ich helfen kann.

Erstgespräch anfragen