Automatisches Hetzner‑DNS‑Updates via Bash Skript

  Lars Müller   Lesezeit: 10 Minuten  🗪 10 Kommentare Auf Mastodon ansehen

Du möchtest einen Server daheim betreiben und deine Domain anstatt einer DynDNS Domain nutzen? Hier gibt es die passende Anleitung.

automatisches hetzner‑dns‑updates via bash skript

Ein weiteres Tutorial von mir; Ich betreibe seit Januar einen Server daheim wo meine Nextcloud, mein Blog und andere Dinge drauf laufen und auch öffentlich erreichbar sein sollen. Ich könnte natürlich auch die DynDNS Domain der Fritzbox nutzen oder einen unterstützten DynDNS Anbieter, aber die Domain ist dann nicht ganz so schick. Deswegen habe ich mir den Weg über den Hetzner DNS Server überlegt. Netterweise bietet Hetzner eine API für solche sachen an. So spart man sich den Weg jedesmal in die DNS Konsole gehen zu müssen. Auch ein Anlegen neuer Domains/Subdomains ist darüber möglich.

Aus diesem Grunde die folgende Anleitung mit dem passenden Skript.

Das Skript ist so geschrieben dass es keine unnötigen Änderungen und API‑Calls macht. Mit diesem Bash‑Skript prüfst du zuerst, ob der bestehende A‑ oder AAAA‑Record schon zur aktuellen IP passt. Nur wenn sich die IP tatsächlich geändert hat, wird der alte Record gelöscht und ein neuer angelegt.

Warum automatische DNS‑Updates?

Viele Internet‑Anbieter vergeben dynamische IP‑Adressen. Wenn sich deine IPv4 oder IPv6 ändert, ist deine Domain nicht mehr erreichbar – frustrierend wenn man permanent etwas verfügbar machen möchte. Ein automatischer DNS‑Update sorgt dafür, dass deine Domain immer auf die richtige Adresse zeigt, ohne dass du manuell eingreifen musst.

Das Skript muss auf dem Server laufen, der am Router angeschlossen ist. In meinem Fall habe ich das Skript im Pfad /usr/local/bin abgelegt.

So funktioniert das Skript

  1. IP abrufen
    Das Skript liest deine aktuelle IPv4 und IPv6 per curl von externen Diensten aus (z.B. ipv4.icanhazip.com).

  2. Zone‑ID ermitteln
    Für jede Domain holt es sich per Hetzner‑API die passende Zone‑ID.

  3. Bestands‑Check
    Es liest die vorhandenen A‑ bzw. AAAA‑Einträge aus und vergleicht deren Wert mit der aktuellen IP.

  4. Löschen nur bei Abweichung
    Passt der Eintrag bereits, bleibt er bestehen und das Skript überspringt ihn. Weicht er ab oder fehlt er, löscht das Skript alle alten Einträge dieses Typs und legt einen neuen an.

  5. TTL und Performance
    Mit einem niedrigen TTL‑Wert (z.B. 60 Sekunden) erreichen Änderungen schnell alle DNS‑Server. Gleichzeitig sparst du API‑Calls, weil nur bei echten Änderungen gearbeitet wird.

Abhängigkeiten installieren

Damit das Skript funktionieren kann, müssen 2 Abhängigkeiten installiert werden, nämlich jq und curl.

sudo apt update && sudo apt install curl jq

Das Skript

#!/bin/bash

set -euo pipefail

# Hetzner API-Key
HETZNER_API_KEY="SomeSecretKeyHetzner"
HETZNER_API_URL="https://dns.hetzner.com/api/v1"

# Domains mit A/AAAA-Records
DOMAINS=(
    "domain.tld"
    "domainb.tld"
    "sub.domain.tld"
)

# CNAME-Zuordnungen
declare -A CNAME_RECORDS=(
    ["www.domain.tld"]="domain.tld"
    ["www.domainb.tld"]="domainb.tld"
)

# Aktuelle öffentliche IP-Adressen
CURRENT_IPV4=$(curl -s https://ipv4.icanhazip.com)
CURRENT_IPV6=$(curl -s --max-time 5 https://ipv6.icanhazip.com || true)

# Funktion zum Löschen eines DNS-Records
delete_record() {
    local RECORD_ID=$1
    if [[ -n "$RECORD_ID" && "$RECORD_ID" != "null" ]]; then
        echo "🗑 Lösche Eintrag: $RECORD_ID"
        curl -s -X DELETE "$HETZNER_API_URL/records/$RECORD_ID" \
            -H "Auth-API-Token: $HETZNER_API_KEY" > /dev/null
    fi
}

# Funktion zum Aktualisieren von A- und AAAA-Records
update_domain() {
    local DOMAIN=$1
    local MAIN_DOMAIN=$(echo "$DOMAIN" | awk -F'.' '{print $(NF-1)"."$NF}')
    local SUBDOMAIN=${DOMAIN/.$MAIN_DOMAIN/}
    [[ "$SUBDOMAIN" == "$DOMAIN" ]] && SUBDOMAIN="@"

    local ZONE_ID=$(curl -s -H "Auth-API-Token: $HETZNER_API_KEY" "$HETZNER_API_URL/zones" |
        jq -r ".zones[] | select(.name==\"$MAIN_DOMAIN\") | .id")

    if [[ -z "$ZONE_ID" ]]; then
        echo "❌ Zone für $DOMAIN nicht gefunden!"
        return
    fi

    local RECORDS=$(curl -s -H "Auth-API-Token: $HETZNER_API_KEY" "$HETZNER_API_URL/records?zone_id=$ZONE_ID")

    # A-Record prüfen
    local RECORD_A=$(echo "$RECORDS" | jq -r ".records[] | select(.name==\"$SUBDOMAIN\" and .type==\"A\")")
    local EXISTING_IPV4=$(echo "$RECORD_A" | jq -r ".value")
    local RECORD_ID_A=$(echo "$RECORD_A" | jq -r ".id")

    if [[ "$EXISTING_IPV4" != "$CURRENT_IPV4" ]]; then
        delete_record "$RECORD_ID_A"
        echo "[$DOMAIN] ➡️ Setze neuen A-Record auf $CURRENT_IPV4"
        curl -s -X POST "$HETZNER_API_URL/records" \
            -H "Auth-API-Token: $HETZNER_API_KEY" \
            -H "Content-Type: application/json" \
            -d "{
                \"zone_id\": \"$ZONE_ID\",
                \"type\": \"A\",
                \"name\": \"$SUBDOMAIN\",
                \"value\": \"$CURRENT_IPV4\",
                \"ttl\": 60
            }" > /dev/null
    else
        echo "[$DOMAIN] ✅ A-Record bereits aktuell ($CURRENT_IPV4)"
    fi

    # AAAA-Record prüfen
    if [[ -n "$CURRENT_IPV6" ]]; then
        local RECORD_AAAA=$(echo "$RECORDS" | jq -r ".records[] | select(.name==\"$SUBDOMAIN\" and .type==\"AAAA\")")
        local EXISTING_IPV6=$(echo "$RECORD_AAAA" | jq -r ".value")
        local RECORD_ID_AAAA=$(echo "$RECORD_AAAA" | jq -r ".id")

        if [[ "$EXISTING_IPV6" != "$CURRENT_IPV6" ]]; then
            delete_record "$RECORD_ID_AAAA"
            echo "[$DOMAIN] ➡️ Setze neuen AAAA-Record auf $CURRENT_IPV6"
            curl -s -X POST "$HETZNER_API_URL/records" \
                -H "Auth-API-Token: $HETZNER_API_KEY" \
                -H "Content-Type: application/json" \
                -d "{
                    \"zone_id\": \"$ZONE_ID\",
                    \"type\": \"AAAA\",
                    \"name\": \"$SUBDOMAIN\",
                    \"value\": \"$CURRENT_IPV6\",
                    \"ttl\": 60
                }" > /dev/null
        else
            echo "[$DOMAIN] ✅ AAAA-Record bereits aktuell ($CURRENT_IPV6)"
        fi
    fi
}

# Funktion zum Aktualisieren eines CNAME-Records
update_cname() {
    local SUBDOMAIN=$1
    local TARGET=$2
    local MAIN_DOMAIN=$(echo "$SUBDOMAIN" | awk -F'.' '{print $(NF-1)"."$NF}')

    local ZONE_ID=$(curl -s -H "Auth-API-Token: $HETZNER_API_KEY" "$HETZNER_API_URL/zones" |
        jq -r ".zones[] | select(.name==\"$MAIN_DOMAIN\") | .id")

    if [[ -z "$ZONE_ID" ]]; then
        echo "❌ Zone für $MAIN_DOMAIN nicht gefunden!"
        return
    fi

    local RECORDS=$(curl -s -H "Auth-API-Token: $HETZNER_API_KEY" "$HETZNER_API_URL/records?zone_id=$ZONE_ID")
    local RECORD=$(echo "$RECORDS" | jq -r ".records[] | select(.name==\"$SUBDOMAIN\" and .type==\"CNAME\")")
    local EXISTING_TARGET=$(echo "$RECORD" | jq -r ".value")
    local RECORD_ID=$(echo "$RECORD" | jq -r ".id")

    if [[ "$EXISTING_TARGET" != "$TARGET" ]]; then
        delete_record "$RECORD_ID"
        echo "[$SUBDOMAIN] ➡️ Setze neuen CNAME-Record auf $TARGET"
        curl -s -X POST "$HETZNER_API_URL/records" \
            -H "Auth-API-Token: $HETZNER_API_KEY" \
            -H "Content-Type: application/json" \
            -d "{
                \"zone_id\": \"$ZONE_ID\",
                \"type\": \"CNAME\",
                \"name\": \"$SUBDOMAIN\",
                \"value\": \"$TARGET\",
                \"ttl\": 60
            }" > /dev/null
    else
        echo "[$SUBDOMAIN] ✅ CNAME bereits aktuell ($TARGET)"
    fi
}

# Durchlauf
echo "🌐 Starte DNS-Update..."

for DOMAIN in "${DOMAINS[@]}"; do
    if [[ -n "${CNAME_RECORDS[$DOMAIN]:-}" ]]; then
        update_cname "$DOMAIN" "${CNAME_RECORDS[$DOMAIN]}"
    else
        update_domain "$DOMAIN"
    fi
done

echo "✅ DNS-Update abgeschlossen."

Wenn das Skript korrekt durchläuft, dies kann man testen mit --dry-run, sieht dies so aus:

Abschließende Arbeiten

  • Logfile anlegen: 
    sudo touch /var/log/update-dns.log
  • Cron‑Job einrichten: Lege das Skript z.B. in /usr/local/bin/update-dns.sh ab und starte es alle 15 Minuten über
  • */15 * * * * /usr/local/bin/update-dns.sh >/dev/null 2>&1
  • Logging: Für längere Fehlersuche kannst du Ausgaben in eine Log‑Datei umleiten:

    update-dns.sh >>/var/log/update-dns.log 2>&1

Probiere das Skript aus und behalte deine DNS‑Einträge immer im Griff, ohne unnötig Ressourcen zu verschwenden. Viel Spaß beim Automatisieren!

Das Skript habe ich mittels ChatGPT erstellt,  da ich selber nicht so wirklich der Held bin im programmieren. Das Skript habe ich trotzdem gecheckt und läuft einwandfrei auf meinem Server daheim.

Beitragsbild:  generiert mit ChatGPT

Tags

DNS-Konfiguration, Hetzner, DynDNS

André
Geschrieben von André am 1. Mai 2025 um 15:20

Nice one ^^

Ich habe eine simplere Variante für CF mal geschrieben. Wegen dem Handelskrieg bin ich nun am Überlegen zu Hetzner zu wandern.

Haben leider noch kein CDN und DDoS-Schutz.

Kennt ihr gute (kostenfreie) Anbieter die mit CF da mithalten können?

Lars Müller
Geschrieben von Lars Müller am 1. Mai 2025 um 19:13

Danke danke :-)

Was Cloudflare betrifft bin ich leider raus.

McPringle
Geschrieben von McPringle am 7. Mai 2025 um 12:19

Schau dir mal diese drei Alternativen an, vielleicht ist etwas dabei (das steht auch noch auf meiner viel zu langen Todo Liste): https://european-alternatives.eu/category/ddos-protection-services

Grauzone
Geschrieben von Grauzone am 1. Mai 2025 um 15:55

Ich nutze freedns.afraid.org und habe ein deutlich einfacheres Skript zur Aktualisierung meiner dynamischen IP Adresse meiner eigenen domain. Es fragt die eigene IP Adresse des Systems bei mir daheim via einem angebotenen kostenlosen Service ab und vergleicht diese mit der alten. Bei Änderungen Update machen. Fertig.

Lars Müller
Geschrieben von Lars Müller am 1. Mai 2025 um 19:12

So ähnlich geht das ja mit dem Skript auch. Es fragt über einen externen Dienst ab welche IP aktuell ist, wenn diese nicht mehr übereinstimmt mit der IP in der Hetzner DNS Konsole, wird ein Update gemacht.

McPringle
Geschrieben von McPringle am 1. Mai 2025 um 16:18

Um einen DNS Record bei Hetzner zu aktualisieren, muss dieser nicht vorher gelöscht werden. Ihn vorher zu löschen bedeutet, dass er hinterher - weil er neu angelegt wird - auch eine neue Record ID erhält. Da beim reinen Aktualisieren des Eintrags die Zone ID und Record ID immer gleich bleibt, muss diese nicht jedes mal abgefragt werden und kann daher im Skript oder in einer separaten Datei abgelegt werden. Wenn man sich dann noch die IPv4 und IPv6 in einer Datei lokal merkt, müssen diese nicht erst durch separate API Calls ermittelt werden. Das reduziert die Anzahl der API Calls (und damit auch die Laufzeit und Last). Mein Skript:

#!/bin/bash

# ============================================
# Dynamic IP Updater Script for Hetzner DNS
# ============================================
# - Fetch current public IPv4 and IPv6 from external services
# - Compare with last known IPs
# - Update A and AAAA records via Hetzner DNS API if needed
# - Log all actions and errors
# ============================================

set -euo pipefail

# --------------------------------------------
# Configuration
# --------------------------------------------

# Hetzner DNS API Token and Record Details
HETZNER_API_TOKEN="YOUR_SECRET_DNS_API_TOKEN"
ZONE_ID="YOUR_DNS_ZONE_ID"
RECORD_NAME="YOUR_RECORD_NAME" # can be "@" for your main domain
A_RECORD_ID="YOUR_A_RECORD_ID"
AAAA_RECORD_ID="YOUR_AAAA_RECORD_ID"
TTL="300" # seconds

# Paths for saving the last known IPs
IPV4_FILE="/tmp/cache-dynamic-ipv4"
IPV6_FILE="/tmp/cache-dynamic-ipv6"
LOGFILE="/var/log/update-dynamic-ip.log"

# External services to fetch public IPs
IPV4_SERVICE="https://api4.ipify.org"
IPV6_SERVICE="https://api6.ipify.org"

# --------------------------------------------
# Functions
# --------------------------------------------

log() {
  echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOGFILE"
}

die() {
  log "[ERROR] $1"
  exit 1
}

update_dns_record() {
  local record_id=$1
  local new_value=$2
  local record_type=$3

  curl -s -X PUT "https://dns.hetzner.com/api/v1/records/${record_id}" \
    -H "Content-Type: application/json" \
    -H "Auth-API-Token: ${HETZNER_API_TOKEN}" \
    -d "$(jq -n \
      --arg value "$new_value" \
      --arg type "$record_type" \
      --arg name "$RECORD_NAME" \
      --arg zone_id "$ZONE_ID" \
      --argjson ttl "$TTL" \
      '{value: $value, ttl: $ttl, type: $type, name: $name, zone_id: $zone_id}')" \
    >> "$LOGFILE" 2>&1 || die "Failed to update record ID ${record_id}"
}

# --------------------------------------------
# Fetch current public IPs
# --------------------------------------------

IPV4=$(curl -s "$IPV4_SERVICE") || die "Failed to fetch public IPv4"
IPV6=$(curl -s "$IPV6_SERVICE") || die "Failed to fetch public IPv6"

[[ -n "$IPV4" ]] || die "Public IPv4 is empty"
[[ -n "$IPV6" ]] || die "Public IPv6 is empty"

log "[INFO] Current public IPv4: $IPV4"
log "[INFO] Current public IPv6: $IPV6"

# --------------------------------------------
# Compare with stored IPs
# --------------------------------------------

IPV4_CHANGED=true
IPV6_CHANGED=true

if [[ -f "$IPV4_FILE" ]]; then
  OLD_IPV4=$(cat "$IPV4_FILE")
  if [[ "$OLD_IPV4" == "$IPV4" ]]; then
    IPV4_CHANGED=false
    log "[INFO] IPv4 unchanged ($IPV4)"
  fi
fi

if [[ -f "$IPV6_FILE" ]]; then
  OLD_IPV6=$(cat "$IPV6_FILE")
  if [[ "$OLD_IPV6" == "$IPV6" ]]; then
    IPV6_CHANGED=false
    log "[INFO] IPv6 unchanged ($IPV6)"
  fi
fi

if [[ "$IPV4_CHANGED" == false && "$IPV6_CHANGED" == false ]]; then
  log "[INFO] No changes in IPs - exiting."
  exit 0
fi

# --------------------------------------------
# Update Hetzner DNS records if needed
# --------------------------------------------

if [[ "$IPV4_CHANGED" == true ]]; then
  log "[INFO] Updating A record to new IPv4: $IPV4"
  update_dns_record "$A_RECORD_ID" "$IPV4" "A"
  echo "$IPV4" > "$IPV4_FILE"
fi

if [[ "$IPV6_CHANGED" == true ]]; then
  log "[INFO] Updating AAAA record to new IPv6: $IPV6"
  update_dns_record "$AAAA_RECORD_ID" "$IPV6" "AAAA"
  echo "$IPV6" > "$IPV6_FILE"
fi

log "[INFO] Update completed successfully."

exit 0
Lars Müller
Geschrieben von Lars Müller am 1. Mai 2025 um 19:16

Danke für den Kommentar. Das zwischenspeichern der IP ist eine gute Idee. Vielleicht ändere ich das ab. Möchte natürlich die API Calls reduzieren.

x4ff3
Geschrieben von x4ff3 am 1. Mai 2025 um 20:25

CNAME auf die fritz.box dnydns adresse. problem solved ;)

McPringle
Geschrieben von McPringle am 7. Mai 2025 um 12:17

CNAME funktioniert bei Subdomains, nicht bei Hauptdomains.

Lars Müller
Geschrieben von Lars Müller am 1. Mai 2025 um 20:49

Könnte man auch machen, aber wollte es schicker haben. 😃