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), dedicatedmyip6/myipv6parameters, and protocol-compliantnochgresponses. 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
curlandSQLite3extensions (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
ServerNamewith 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 OptionalExample 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
doneStep 2 - Create your config
sudo cp hetzner_dyndns.config.php.dist hetzner_dyndns.config.phpEdit 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, andapi_orderbelonged to the shut-down legacy API and are now ignored. Simply add aconsole_tokenper realm — the rest of your config can stay as it is.
Step 3 - Put it on the internet (safely)
- Keep
hetzner_dyndns.config.phpout of public Git and file listings. - Ensure
.htaccessrewrites 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 toupdateif empty). Most DynDNS clients send any username plus the password, and you can alsocurlit: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) ornochg <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/updateURL with the password you set. - IPv4 and IPv6 together: either comma-separated via
myip=<IPv4>,<IPv6>or via a dedicated parametermyip6=<IPv6>/myipv6=<IPv6>(Fritz!Box: usemyip=<ipaddr>&myip6=<ip6addr>in the update URL). - IPv6-only/DS-Lite: just send the IPv6 address (
myip6=...withoutmyip) — 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
.htaccessnot applied. Zone not found on console API: wrongzone_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_tokenper realm (DNS write permissions!) and check the log withdebugenabled.
Nerd corner (how it actually works)
- Architecture at a glance
- One PHP file, one config file, one SQLite database. Incoming DynDNS calls (
/nic/updateor/v3/update) are rewritten tohetzner_dyndns.php. - The script authenticates with your shared username/password, parses the hostname and optional
realmoverride, caches the last known IPs and the zone ID in SQLite, and returnsnochgimmediately when nothing changed — without any API call.
- One PHP file, one config file, one SQLite database. Incoming DynDNS calls (
- Configuration deep dive
- Shared settings:
auth_user,auth_password,auth_realm,history_db, and optionaldebug/debug_log. - Notifications: enable
notifications.enabled, pickphporsmtp, set recipients, and decide if you want messages on success, failure, or both. - Realms: each realm holds
ttl,zone_name, andconsole_token. Overridezone_nameif your DynDNS endpoint lives on a subdomain but you update the parent zone.
- Shared settings:
- API flow (Hetzner Console)
- 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. - Fetch RRsets via
/zones/{id}/rrsets(A and AAAA). - 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. - Call
set_recordsto replace the records with the new IPs — only for the address families the client actually supplied (IPv4-only, dual-stack, or IPv6-only). - Failures stay marked
needs_syncand are retried by cron.
- Resolve the zone via
- 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 keysdns_token,dns_endpoint, andapi_orderare ignored (with a hint in the log whendebugis enabled). - A
console_tokenis now required per realm; the SQLite database remains compatible and needs no changes. - After updating, run
php hetzner_dyndns.php --crononce to flush pending work — the CLI cron invocation neither needs nor accepts HTTP credentials.
- The legacy API (
- Multiple zones and realms
- Create one realm per zone or per subdomain. Example: a
gjsi.derealm and addns.gjsi.derealm 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.phpcan print cached IPv4/IPv6 per realm without hitting the HTTP endpoint.
- Create one realm per zone or per subdomain. Example: a
- Notifications and observability
- Email notifications report successes and failures using PHP
mail()or SMTP. debuganddebug_logcapture all API request and response pairs plus notification status.- Cron summaries print totals, successes, and failures per host.
- Email notifications report successes and failures using PHP
- Security and deployment notes
.htaccessblocks 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.