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-10-27
Time to read
11 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.

Update October 2026 (security): The .htaccess shipped with earlier versions did not actually block the SQLite history database — its pattern only matched a file literally named .sqlite3 — so hetzner_dyndns.sqlite3 (hostnames, IP addresses and timestamps; no credentials) was downloadable, and a query string containing /nic/update could bypass the block on direct PHP calls. The new .htaccess denies everything except the two DynDNS endpoints and /.well-known/, refuses every request method except GET/HEAD, and a new .user.ini hardens PHP. Replace .htaccess on existing installations, add .user.ini, and run a PHP release that contains the PHP2Shell fixes (see Step 3).

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
├─ .user.ini                         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
  .user.ini
  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.
  • Use the shipped .htaccess unchanged. It rewrites /nic/update and /v3/update to the script and denies everything else: the PHP files, the SQLite database, the debug log, the config template, dotfiles and backups all answer 403, and every request method except GET/HEAD is refused before PHP sees it. Only /.well-known/ stays reachable for ACME challenges. Your web server must honour .htaccess (AllowOverride All plus mod_rewrite, see Step 0); on nginx you have to deny those paths yourself.
  • Keep the shipped .user.ini next to the script. PHP-FPM and CGI setups (typical web hosting) read it: it switches display_errors off, disables session upload progress and caps POST sizes. With libapache2-mod-php as in Step 0 it is ignored — set the same values in /etc/php/*/apache2/php.ini instead.
  • Run a PHP release that contains the October 2026 PHP2Shell fixes (CVE-2026-109618, CVE-2026-109619): 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later. On shared hosting select the PHP version in the hoster's control panel. .htaccess and .user.ini reduce the attack surface but do not replace the patch.
  • If your host needs it, set the PHP user to be able to write the SQLite file and debug log.
  • Verify the lock-down once from outside (expected status codes in the comments):
    curl -sI https://ddns.example.com/hetzner_dyndns.sqlite3 | head -1                      # 403
    curl -sI "https://ddns.example.com/hetzner_dyndns_listhosts.php?/nic/update" | head -1  # 403
    curl -sI https://ddns.example.com/nic/update | head -1                                  # 401

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.
  • 404 on /nic/update: .htaccess is not applied (AllowOverride All and mod_rewrite missing).
  • 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 is deny-by-default: only the two DynDNS endpoints and /.well-known/ are reachable, and only via GET/HEAD. Earlier versions used an anchored filename pattern (^\.sqlite3$) that never matched hetzner_dyndns.sqlite3, and their "no direct PHP" rule could be bypassed with a query string containing /nic/update; both were fixed in October 2026.
    • .user.ini disables session upload progress (the component behind CVE-2026-109619 and CVE-2026-115560), hides PHP errors and caps POST sizes. The scripts additionally force display_errors off, so an uncaught error never reveals server paths.
    • 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

Get new tutorials via RSS / Atom

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
Report Issue

Get new tutorials via RSS / Atom