Skip to content

Instantly share code, notes, and snippets.

@OnkelDom
Last active February 15, 2026 19:40
Show Gist options
  • Select an option

  • Save OnkelDom/d29a5696553557bdb11bdb60b7896420 to your computer and use it in GitHub Desktop.

Select an option

Save OnkelDom/d29a5696553557bdb11bdb60b7896420 to your computer and use it in GitHub Desktop.

Revisions

  1. OnkelDom revised this gist Feb 15, 2026. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions hetzner-cloud-ddns.md
    Original file line number Diff line number Diff line change
    @@ -249,10 +249,10 @@ main "$@"

    ## Create Cronjob

    To run the script automaticly every 5 minutes, add the following file to ``.
    To run the script automaticly every 5 minutes, add the following file to `/etc/cron.d/hetzner-cloud-ddns`.

    ```bash
    */5 * * * * /data/hetzner_ddns/hetzner-cloud-ddns.sh /data/hetzner_ddns/hetzner-cloud-ddns.conf >> /var/log/hetzner-ddns-cron.log 2>&1
    */5 * * * * root /data/hetzner_ddns/hetzner-cloud-ddns.sh /data/hetzner_ddns/hetzner-cloud-ddns.conf >> /var/log/hetzner-ddns-cron.log 2>&1
    ```

    -----
  2. OnkelDom created this gist Feb 15, 2026.
    286 changes: 286 additions & 0 deletions hetzner-cloud-ddns.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,286 @@
    # Unifi Dream Machine SE Hetzner Cloud API DynDNS

    This script ist to use dynmic dns updates with the new Hetzner cloud API.

    Login via ssh to your Dream Machine.

    **Create Folder**: `mkdir -p /data/hetzner_ddns`

    Add the following config to `/data/hetzner_ddns/hetzner-cloud-ddns.conf`

    ```bash
    # Hetzner Cloud API token
    HETZNER_API_TOKEN="PASTE_TOKEN_HERE"

    # IMPORTANT: This is the Zone ID (not the zone name).
    # Get it via: curl -H "Authorization: Bearer $HETZNER_API_TOKEN" https://api.hetzner.cloud/v1/zones
    ZONE_ID="YOUR_ZONE_ID"

    # Comma-separated FQDNs to update (A + AAAA by default)
    # Dont use fqdn's. The zone name is not needed and adds double entries.
    RECORD_FQDNS="vpn"

    # TTL in seconds
    TTL=86400

    # IP discovery (UDM friendly)
    IPV4_URL="https://ifconfig.io"
    IPV6_URL="https://ifconfig.io"

    # Cache directory
    STATE_DIR="/tmp"

    # Optional toggles
    UPDATE_A=1
    UPDATE_AAAA=1
    ```

    Chane persmissions to protect your API key.

    `chmod 600 /data/hetzner_ddns/hetzner-cloud-ddns.conf`

    -----

    Add the following code to `/data/hetzner_ddns/hetzner-cloud-ddns.sh`

    ```bash
    #!/usr/bin/env bash
    #
    # Hetzner Cloud DNS Dynamic Update Script (Cloud API)
    # Updates A + AAAA RRsets with the current public IP address(es)
    # API Docs: https://docs.hetzner.cloud/reference/cloud#tag/zones
    #
    # Usage: ./hetzner-cloud-ddns.sh [config_file]

    set -euo pipefail

    # ============ DEFAULT CONFIG PATH ============
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    CONFIG_FILE="${1:-$SCRIPT_DIR/hetzner-cloud-ddns.conf}"

    # ============ END CONFIG ============
    API_BASE="${API_BASE:-https://api.hetzner.cloud/v1}"
    LOG_PREFIX="[hetzner-ddns]"

    log() { echo "$LOG_PREFIX $(date '+%Y-%m-%d %H:%M:%S') $*"; }
    error() { echo "$LOG_PREFIX $(date '+%Y-%m-%d %H:%M:%S') ERROR: $*" >&2; exit 1; }

    require_cmd() { command -v "$1" >/dev/null 2>&1 || error "Missing dependency: $1"; }

    # ============ LOAD CONFIG ============
    load_config() {
    [[ -f "$CONFIG_FILE" ]] || error "Config file not found: $CONFIG_FILE"

    # shellcheck source=/dev/null
    source "$CONFIG_FILE"

    [[ -n "${HETZNER_API_TOKEN:-}" ]] || error "HETZNER_API_TOKEN not set in $CONFIG_FILE"
    [[ -n "${ZONE_ID:-}" ]] || error "ZONE_ID not set in $CONFIG_FILE (numeric/uuid id from Hetzner Cloud DNS)"
    [[ -n "${RECORD_FQDNS:-}" ]] || error "RECORD_FQDNS not set in $CONFIG_FILE (e.g. vpn.lenmail.de)"

    # Optional
    TTL="${TTL:-86400}"
    STATE_DIR="${STATE_DIR:-/tmp}"

    # IP services (UDM friendly)
    IPV4_URL="${IPV4_URL:-https://ifconfig.io}"
    IPV6_URL="${IPV6_URL:-https://ifconfig.io}"

    # Which types to manage
    UPDATE_A="${UPDATE_A:-1}"
    UPDATE_AAAA="${UPDATE_AAAA:-1}"

    mkdir -p "$STATE_DIR" 2>/dev/null || true
    }

    # --------- Public IP helpers ----------
    get_public_ipv4() {
    local ip
    ip="$(curl -4 -fsS --max-time 10 "$IPV4_URL" | tr -d '[:space:]' || true)"
    [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
    echo "$ip"
    }

    get_public_ipv6() {
    local ip
    ip="$(curl -6 -fsS --max-time 10 "$IPV6_URL" | tr -d '[:space:]' || true)"

    # Filter link-local
    if [[ -z "$ip" ]] || echo "$ip" | grep -qi '^fe80:'; then
    return 1
    fi

    # Very rough plausibility check (keeps it simple)
    [[ "$ip" =~ ^[0-9a-fA-F:]+$ ]] || return 1
    echo "$ip"
    }

    cache_file_for() {
    local fqdn="$1"
    local type="$2"
    # ensure filesystem-safe name
    echo "${STATE_DIR}/hetzner-ddns-${ZONE_ID}-${fqdn}-${type}.cache" | tr '/:' '__'
    }

    ip_changed() {
    local current_ip="$1"
    local cache_file="$2"
    local cached_ip=""

    if [[ -f "$cache_file" ]]; then
    cached_ip="$(cat "$cache_file" 2>/dev/null || true)"
    fi
    [[ "$cached_ip" != "$current_ip" ]]
    }

    save_ip_cache() {
    local ip="$1"
    local cache_file="$2"
    echo "$ip" > "$cache_file"
    }

    # --------- Hetzner Cloud API helpers ----------
    api_request() {
    local method="$1"
    local url="$2"
    local data="${3:-}"

    if [[ -n "$data" ]]; then
    curl -sS -w "\n%{http_code}" --max-time 30 \
    -X "$method" \
    -H "Authorization: Bearer $HETZNER_API_TOKEN" \
    -H "Content-Type: application/json" \
    -d "$data" \
    "$url"
    else
    curl -sS -w "\n%{http_code}" --max-time 30 \
    -X "$method" \
    -H "Authorization: Bearer $HETZNER_API_TOKEN" \
    "$url"
    fi
    }

    set_rrset() {
    local zone_id="$1"
    local fqdn="$2" # full name, e.g. vpn.lenmail.de
    local type="$3" # A or AAAA
    local value="$4"
    local ttl="$5"

    # API expects "name" as FQDN (as per docs examples), keep as given.
    # Normalize
    fqdn="$(echo "$fqdn" | tr '[:upper:]' '[:lower:]')"
    type="$(echo "$type" | tr '[:lower:]' '[:upper:]')"

    local request_body
    request_body='{"name":"'"$fqdn"'","type":"'"$type"'","ttl":'"$ttl"',"records":[{"value":"'"$value"'"}]}'

    local url="$API_BASE/zones/$zone_id/rrsets"
    local response http_code body
    response="$(api_request "POST" "$url" "$request_body")"
    http_code="$(echo "$response" | tail -n1)"
    body="$(echo "$response" | sed '$d')"

    if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
    log "RRset set successful: $fqdn $type -> $value (HTTP $http_code)"
    return 0
    fi

    log "API error setting RRset (HTTP $http_code): $body"
    return 1
    }

    main() {
    require_cmd curl
    load_config

    log "Getting current public IPs..."
    local ip4="" ip6=""
    if [[ "${UPDATE_A}" == "1" ]]; then ip4="$(get_public_ipv4 || true)"; fi
    if [[ "${UPDATE_AAAA}" == "1" ]]; then ip6="$(get_public_ipv6 || true)"; fi

    log "Current IPv4: ${ip4:-<none>}"
    log "Current IPv6: ${ip6:-<none>}"

    if [[ -z "$ip4" && -z "$ip6" ]]; then
    error "Failed to get public IPv4/IPv6"
    fi

    IFS=',' read -r -a FQDNS <<< "$RECORD_FQDNS"

    for fqdn in "${FQDNS[@]}"; do
    fqdn="$(echo "$fqdn" | xargs)"
    [[ -z "$fqdn" ]] && continue

    # A
    if [[ -n "$ip4" && "${UPDATE_A}" == "1" ]]; then
    local c4
    c4="$(cache_file_for "$fqdn" "A")"
    if ip_changed "$ip4" "$c4"; then
    log "Updating A $fqdn -> $ip4"
    set_rrset "$ZONE_ID" "$fqdn" "A" "$ip4" "$TTL" || error "Failed to update A for $fqdn"
    save_ip_cache "$ip4" "$c4"
    else
    log "A $fqdn unchanged, skipping"
    fi
    fi

    # AAAA
    if [[ -n "$ip6" && "${UPDATE_AAAA}" == "1" ]]; then
    local c6
    c6="$(cache_file_for "$fqdn" "AAAA")"
    if ip_changed "$ip6" "$c6"; then
    log "Updating AAAA $fqdn -> $ip6"
    set_rrset "$ZONE_ID" "$fqdn" "AAAA" "$ip6" "$TTL" || error "Failed to update AAAA for $fqdn"
    save_ip_cache "$ip6" "$c6"
    else
    log "AAAA $fqdn unchanged, skipping"
    fi
    fi
    done

    log "Done."
    }

    main "$@"
    ```

    -----

    ## Create Cronjob

    To run the script automaticly every 5 minutes, add the following file to ``.

    ```bash
    */5 * * * * /data/hetzner_ddns/hetzner-cloud-ddns.sh /data/hetzner_ddns/hetzner-cloud-ddns.conf >> /var/log/hetzner-ddns-cron.log 2>&1
    ```

    -----

    ## Example Logs

    Added new entries

    ```bash
    /data/hetzner_ddns/hetzner-cloud-ddns.sh /data/hetzner_ddns/hetzner-cloud-ddns.conf
    [hetzner-ddns] 2026-02-15 20:25:42 Getting current public IPs...
    [hetzner-ddns] 2026-02-15 20:25:43 Current IPv4: 217.xx.xx.xx
    [hetzner-ddns] 2026-02-15 20:25:43 Current IPv6: 2003:a:xx:xxxx:xxxx:8192:a46a:85e9
    [hetzner-ddns] 2026-02-15 20:25:43 Updating A vpn -> 217.xx.xx.xx
    [hetzner-ddns] 2026-02-15 20:25:48 RRset set successful: vpn A -> 217.xx.xx.xx (HTTP 201)
    [hetzner-ddns] 2026-02-15 20:25:48 Updating AAAA vpn -> 2003:a:xx:xxxx:xxxx:8192:a46a:85e9
    [hetzner-ddns] 2026-02-15 20:25:48 RRset set successful: vpn AAAA -> 2003:a:xx:xxxx:xxxx:8192:a46a:85e9 (HTTP 201)
    [hetzner-ddns] 2026-02-15 20:25:48 Done.
    ```

    Rerun

    ```bash
    /data/hetzner_ddns/hetzner-cloud-ddns.sh /data/hetzner_ddns/hetzner-cloud-ddns.conf
    [hetzner-ddns] 2026-02-15 20:34:11 Getting current public IPs...
    [hetzner-ddns] 2026-02-15 20:34:22 Current IPv4: 217.xx.xx.xx
    [hetzner-ddns] 2026-02-15 20:34:22 Current IPv6: 2003:a:xx:xxxx:xxxx:8192:a46a:85e9
    [hetzner-ddns] 2026-02-15 20:34:22 A vpn unchanged, skipping
    [hetzner-ddns] 2026-02-15 20:34:22 AAAA vpn unchanged, skipping
    [hetzner-ddns] 2026-02-15 20:34:22 Done.
    ```