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=1Chane 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 "$@"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>&1Added 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.