Get Rewarded! We will reward you with up to €50 credit on your account for every tutorial that you write and we publish!

Hetzner DynDNS Bridge for the Hetzner Console API

profile picture
Author
woehrl
Published
2026-08-07
Time to read
10 minutes reading time

About the author- S/W Architect, Full-Stack Senior Developer

Introduction

Need your IPv4/IPv6 address to follow you around the internet without babysitting your DNS? This tutorial walks you through a small PHP script that pretends to be a DynDNS server, talks to the Hetzner Console API, and plays nice with routers like a Fritz!Box. The first half is a hand-holding setup guide. The second half is the nerd zone with all the gory details.

Update August 2026: Hetzner shut down the old DNS Console (dns.hetzner.com) and its legacy API in May 2026; remaining zones were migrated to the Hetzner Console automatically. This tutorial and the script have been updated accordingly and now use the Hetzner Console API exclusively. Also new: support for IPv6-only connections (DS-Lite), dedicated myip6/myipv6 parameters, and protocol-compliant nochg responses. If you still run an older version of the script, see the migration notes in the nerd corner.

Prerequisites

  • A Hetzner account with at least one DNS zone in the Hetzner Console.
  • PHP with the curl and SQLite3 extensions (typical web hosting or a small VM works fine).
  • Somewhere to drop a PHP file and run cron every few minutes.
  • A client that can call a DynDNS-style URL (router, NAS, or a simple curl command).
  • The A/AAAA records you want to update must already exist in the zone — the script only changes values and deliberately never creates records.

Step 0 - Minimalistic example setup on Debian/Ubuntu

If you start from a fresh minimal Debian/Ubuntu host, this gets you to a working test endpoint:

  • Install prerequisites and add directory for scripts
    sudo apt update
    sudo apt install -y apache2 libapache2-mod-php php-cli php-curl php-sqlite3
    sudo a2enmod rewrite
    
    sudo mkdir -p /var/www/hetzner-ddns
    sudo chown -R www-data:www-data /var/www/hetzner-ddns

  • Create a minimal Apache site

    Replace the value of ServerName with your DynDNS endpoint. Use HTTPS in production (Let's Encrypt works fine).

    cat <<'EOF' | sudo tee /etc/apache2/sites-available/hetzner-ddns.conf
    <VirtualHost *:80>
        ServerName ddns.example.com
        DocumentRoot /var/www/hetzner-ddns
        <Directory /var/www/hetzner-ddns>
            AllowOverride All
            Require all granted
        </Directory>
    </VirtualHost>
    EOF

  • Enable site
    sudo a2ensite hetzner-ddns
    sudo apachectl configtest
    sudo systemctl reload apache2

Step 1 - Grab the files

Use the files bundled with this tutorial in tutorials/hetzner-ddns-bridge/scripts:

Mirrored from https://github.com/woehrl/hetzner-dyndns, commit fbb3728

Copy the files to your web space or small VM. If you use the minimalistic example setup above, you need to save the files in /var/www/hetzner-ddns.

You need at least:

/var/www/hetzner-ddns
├─ hetzner_dyndns.php
├─ hetzner_dyndns.config.php.dist
├─ .htaccess                         The one provided on GitHub
└─ hetzner_dyndns_listhosts.php      Optional

Example commands:

export path="https://raw.githubusercontent.com/hetzneronline/community-content/refs/heads/master/tutorials/hetzner-ddns-bridge/scripts"
cd /var/www/hetzner-ddns

# Run this in the terminal to set the filenames
files=(
  hetzner_dyndns.php
  hetzner_dyndns.config.php.dist
  .htaccess
  hetzner_dyndns_listhosts.php
)

# Run this in the terminal to copy the files
for f in "${files[@]}"; do
  curl "$path/$f" | sudo tee "$f" >/dev/null
done

Step 2 - Create your config

sudo cp hetzner_dyndns.config.php.dist hetzner_dyndns.config.php

Edit hetzner_dyndns.config.php:

Description
auth_user Optional: Username for HTTP Basic Auth. If empty, it defaults to update.
auth_password Set a strong shared password. Your router will use this.
console_token Create a project API token with DNS read/write permissions in the Hetzner Console and paste it here. Required per realm.
zone_name If you want to update the IP address of a subdomain (e.g. sub.example.com), specify the parent domain (e.g. example.com) here.
auth_realm Optionally: Pick a label (anything friendly like "dynbridge").
history_db Optionally: Point to a writable path (for example
DIR . '/hetzner_dyndns.sqlite3').
TTL Optionally: Adjust TTL per realm if you want faster or slower propagation.

Note for existing installations: The former settings dns_token, dns_endpoint, and api_order belonged to the shut-down legacy API and are now ignored. Simply add a console_token per realm — the rest of your config can stay as it is.

Step 3 - Put it on the internet (safely)

  • Keep hetzner_dyndns.config.php out of public Git and file listings.
  • Ensure .htaccess rewrites DynDNS endpoints to the script so old clients work:
    RewriteEngine On
    RewriteRule ^(nic/update|v3/update)$ hetzner_dyndns.php [L,QSA]
  • If your host needs it, set the PHP user to be able to write the SQLite file and debug log.

Step 4 - Test an update

  • Build the test URL (substitute your domain and desired host):
    https://your-ddns-host.example.com/nic/update?hostname=myhost.example.com&myip=203.0.113.10
  • Use HTTP Basic Auth with your auth_user/auth_password (user defaults to update if empty). Most DynDNS clients send any username plus the password, and you can also curl it:
    curl -u update:yourSecret \
      "https://your-ddns-host.example.com/nic/update?hostname=myhost.example.com&myip=$(curl -4 https://ip.hetzner.com)"
  • A happy response is good <ip> (updated) or nochg <ip> (nothing to do). Anything else means check the debug log.
  • If you get "No matching rrset found", the A/AAAA record does not exist yet: create it once in the Hetzner Console, then the update will succeed.

Step 5 - Make it automatic

  • Add cron every 5 minutes (tweak as you like):
    */5 * * * * php /path/to/hetzner_dyndns.php --cron --realm=default
  • Point your router or NAS DynDNS profile to the same nic/update URL with the password you set.
  • IPv4 and IPv6 together: either comma-separated via myip=<IPv4>,<IPv6> or via a dedicated parameter myip6=<IPv6> / myipv6=<IPv6> (Fritz!Box: use myip=<ipaddr>&myip6=<ip6addr> in the update URL).
  • IPv6-only/DS-Lite: just send the IPv6 address (myip6=... without myip) — the script then updates only the AAAA record and deliberately never writes a carrier-grade IPv4 into the A record.
  • Legacy clients can still send X-Authentication: <password> or ?p=<password> (password-only), but Basic Auth is recommended.

Step 6 - Quick troubleshooting checklist

  • 401 or prompt for auth: password mismatch or .htaccess not applied.
  • Zone not found on console API: wrong zone_name, the zone lives in a different Console project, or the token belongs to the wrong project.
  • No matching rrset found: create the A/AAAA record in the zone once — the script deliberately never creates records.
  • SQLite write errors: fix file permissions or move the DB to a writable folder.
  • Nothing changes: verify the console_token per realm (DNS write permissions!) and check the log with debug enabled.

Nerd corner (how it actually works)

  • Architecture at a glance
    • One PHP file, one config file, one SQLite database. Incoming DynDNS calls (/nic/update or /v3/update) are rewritten to hetzner_dyndns.php.
    • The script authenticates with your shared username/password, parses the hostname and optional realm override, caches the last known IPs and the zone ID in SQLite, and returns nochg immediately when nothing changed — without any API call.

  • Configuration deep dive
    • Shared settings: auth_user, auth_password, auth_realm, history_db, and optional debug/debug_log.
    • Notifications: enable notifications.enabled, pick php or smtp, set recipients, and decide if you want messages on success, failure, or both.
    • Realms: each realm holds ttl, zone_name, and console_token. Override zone_name if your DynDNS endpoint lives on a subdomain but you update the parent zone.

  • API flow (Hetzner Console)
    1. Resolve the zone via /zones?name=<zone> using the Hetzner Console API token — the zone ID is then cached in SQLite, and stale IDs heal themselves with a fresh lookup.
    2. Fetch RRsets via /zones/{id}/rrsets (A and AAAA).
    3. Match the RRset name to your host exactly (handles @ at the apex). Only the exactly matching record is updated; there are intentionally no fallbacks to parent records.
    4. Call set_records to replace the records with the new IPs — only for the address families the client actually supplied (IPv4-only, dual-stack, or IPv6-only).
    5. Failures stay marked needs_sync and are retried by cron.

  • Migrating from older script versions
    • The legacy API (dns.hetzner.com/api/v1) was shut down by Hetzner in May 2026; the corresponding code path has been removed. The config keys dns_token, dns_endpoint, and api_order are ignored (with a hint in the log when debug is enabled).
    • A console_token is now required per realm; the SQLite database remains compatible and needs no changes.
    • After updating, run php hetzner_dyndns.php --cron once to flush pending work — the CLI cron invocation neither needs nor accepts HTTP credentials.

  • Multiple zones and realms
    • Create one realm per zone or per subdomain. Example: a gjsi.de realm and a ddns.gjsi.de realm with different tokens and TTLs.
    • Use realm=<name> in the DynDNS query string if you host multiple domains with the same endpoint.
    • The optional CLI helper hetzner_dyndns_listhosts.php can print cached IPv4/IPv6 per realm without hitting the HTTP endpoint.

  • Notifications and observability
    • Email notifications report successes and failures using PHP mail() or SMTP.
    • debug and debug_log capture all API request and response pairs plus notification status.
    • Cron summaries print totals, successes, and failures per host.

  • Security and deployment notes
    • .htaccess blocks direct hits to the PHP and SQLite files and only exposes the DynDNS endpoints.
    • Keep tokens scoped to DNS and rotate them periodically.
    • Password comparisons are timing-safe via hash_equals().
    • Ensure the SQLite DB and debug log are writable by the PHP user; keep them out of public web roots when possible.
    • Use HTTPS on your DynDNS endpoint. Plain HTTP would leak your shared password. Also note that ?p=<password> ends up in server logs — prefer Basic Auth whenever your client supports it.

Conclusion

The bridge gives you a friendly DynDNS endpoint built on the Hetzner Console API — including IPv6-only support, retry logic, and clean dyndns2 protocol behavior. Start with the quickstart steps to get updates flowing, then dip into the nerd corner when you want to tune realms, notifications, or migrate from an older version of the script.

License: MIT
Want to contribute?

Get Rewarded: Get up to €50 in credit! Be a part of the community and contribute. Do it for the money. Do it for the bragging rights. And do it to teach others!

Report Issue
Try Hetzner Cloud

Get €20/$20 free credit!

Valid until: 31 December 2026 Valid for: 3 months and only for new customers
Get started
Want to contribute?

Get Rewarded: Get up to €50 credit on your account for every tutorial you write and we publish!

Find out more