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.
Hetzner Cloud UDMSE DynDNS IPv4 and IPv6

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

# 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

#!/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 /etc/cron.d/hetzner-cloud-ddns.

*/5 * * * * root /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

/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

/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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment