Dynamic DNS

Chapter 7 — Dynamic DNS

Consumer ISPs like Virgin Media assign dynamic IP addresses — the same address isn't guaranteed to stick. When your IP changes, any DNS A record pointing directly at your home IP becomes stale and your server becomes unreachable. Dynamic DNS solves this automatically: a daemon on your server detects the change and updates the DNS record within minutes. This chapter covers how DDNS works, when Cloudflare Tunnel removes the requirement entirely, and two practical implementations: DuckDNS (simplest) and Cloudflare API (best for the existing setup).

What this chapter covers: Why home IPs change and how DDNS responds. When Cloudflare Tunnel makes DDNS irrelevant for web traffic (but not for SSH). DDNS provider options. DuckDNS setup with a cron job. ddclient configured against the Cloudflare API — updates your own A record automatically. systemd timer as a modern cron replacement. Alerting when your IP changes. Troubleshooting stale records and failed updates.

The Dynamic IP Problem

Static IP addresses cost extra on consumer broadband and Virgin Media doesn't offer them on residential plans. Your IP is leased by DHCP from the ISP and renewed periodically — or reassigned whenever the router reboots, after a long power cut, or just at the ISP's discretion.

Without DDNS — IP changes break DNS: Day 1: osztromok.com A → 82.2.236.221 ← your IP today Visitors → 82.2.236.221 → your server ✓ works Router reboots / ISP renews lease: Day 3: Your new IP = 82.3.45.67 osztromok.com A still → 82.2.236.221 (stale!) Visitors → 82.2.236.221 → nobody home ✗ broken With DDNS — daemon detects change and updates the record: Day 3: DDNS daemon sees 82.3.45.67 ≠ 82.2.236.221 Calls Cloudflare API → updates A record → 82.3.45.67 (within ~5 minutes of IP change) Visitors → 82.3.45.67 → your server ✓ works again

The key parameters that determine downtime are the detection interval (how often the daemon checks your IP) and the DNS TTL (how long resolvers cache the old IP). If you check every 5 minutes and TTL is 60 seconds, maximum downtime is about 6 minutes.

When Cloudflare Tunnel Removes the DDNS Requirement

If your web traffic uses Cloudflare Tunnel (Chapter 4), your home IP is not in the DNS records for your website at all. The DNS records point to Cloudflare's edge (the tunnel CNAME), not your home IP. The tunnel itself is an outbound connection — it reconnects automatically with whatever IP your router currently has.

Tunnel setup — your IP is completely invisible: osztromok.com CNAME → abc123.cfargotunnel.com (Cloudflare's servers) Visitors never see or connect to 82.2.236.221 When your IP changes: - cloudflared daemon reconnects to Cloudflare outbound (with new IP) - No DNS record needs updating - Visitors are unaffected ✓ Website DDNS: NOT needed (tunnel handles it) ✗ SSH access DDNS: NEEDED if you SSH in by IP or home hostname
DDNS is still worth setting up for SSH access. Even with the tunnel handling web traffic, if you ever want to SSH into your server from outside your home network, you need a way to find its current IP. The common approach: keep a separate A record for home.osztromok.com or ssh.osztromok.com updated by DDNS — not proxied through Cloudflare (so it exposes the real IP directly for SSH). This record doesn't route web traffic and doesn't need the tunnel.

DDNS Provider Options

Cloudflare API (Recommended)
Update your own A records directly through Cloudflare's API. No third-party service. Works because your DNS is already on Cloudflare.

  • Use your own domain (osztromok.com)
  • Update any record you control
  • Works with ddclient or a curl script
  • Free — uses your existing Cloudflare account
Best for: keeping ssh.osztromok.com pointing at your real IP.
DuckDNS (Simplest)
Free service with a simple curl-based update. Gives you a yourname.duckdns.org subdomain.

  • Completely free, no credit card
  • Login with GitHub/Google/etc.
  • One-liner curl update command
  • Subdomain is .duckdns.org (not your own domain)
Best for: quick setup to test DDNS, or SSH access when you don't need your own domain name.
No-IP
Popular DDNS provider with free tier. Custom client available.

  • Free tier: 3 hostnames
  • Must confirm free hostnames monthly
  • Supports custom domains on paid plan
  • Has its own official Linux client
Best for: users who want a branded DDNS service with a GUI.
Dynu
Free DDNS with custom domain support and no nag emails.

  • Free custom domain DDNS
  • No monthly confirmation needed
  • Supports ddclient
  • Less well-known but reliable
Best for: free custom domain DDNS without No-IP's renewal hassle.

Checking Your Public IP

DDNS clients work by comparing your current public IP against what's in the DNS record. Checking your IP from the server itself requires querying an external service — ip route only shows your local network IP, not the public one the internet sees.

# Several ways to get your current public IP from the server $ curl -s https://api.ipify.org 82.2.236.221 $ curl -s https://ifconfig.me 82.2.236.221 $ curl -s https://checkip.amazonaws.com 82.2.236.221 # Store it in a variable for scripting $ CURRENT_IP=$(curl -s https://api.ipify.org) $ echo "Public IP: $CURRENT_IP" Public IP: 82.2.236.221 # Compare against what's in DNS $ DNS_IP=$(dig +short ssh.osztromok.com) $ echo "DNS IP: $DNS_IP" DNS IP: 82.2.236.221 ← match = no update needed

Method 1 — DuckDNS (Simplest)

Setup Walkthrough · DuckDNS
Get a free duckdns.org subdomain and keep it updated automatically with a cron job.
1
Register a subdomain. Go to duckdns.org, log in with GitHub/Google, and create a subdomain — e.g. osztromok.duckdns.org. DuckDNS shows you your token on the dashboard. Copy it — you'll need it in the update URL.
2
Create the update script.
$ mkdir -p ~/duckdns $ nano ~/duckdns/duck.sh
Content of the script:
#!/bin/bash TOKEN="your-duckdns-token-here" DOMAIN="osztromok" # just the subdomain part, not .duckdns.org echo url="https://www.duckdns.org/update?domains=${DOMAIN}&token=${TOKEN}&ip=" \ | curl -s -K - -o ~/duckdns/duck.log
$ chmod +x ~/duckdns/duck.sh $ ~/duckdns/duck.sh # run once to test $ cat ~/duckdns/duck.log OK ← "OK" = update succeeded, "KO" = failed (check token)
3
Add a cron job to run every 5 minutes.
$ crontab -e
Add this line at the bottom:
*/5 * * * * ~/duckdns/duck.sh >/dev/null 2>&1
This runs the update script every 5 minutes. DuckDNS is smart enough to do nothing if the IP hasn't changed — it's a cheap curl call.
4
Verify the IP is current.
$ dig +short osztromok.duckdns.org 82.2.236.221 ← should match curl https://api.ipify.org
The DuckDNS subdomain (osztromok.duckdns.org) can be used as an SSH target: ssh philip@osztromok.duckdns.org. It always resolves to your current home IP. TTL is 60 seconds, so after an IP change it's current within a minute of the next cron run.

Method 2 — Cloudflare API with ddclient

ddclient is a mature DDNS daemon that supports dozens of providers including Cloudflare. It runs as a system service, detects IP changes, and updates the DNS record. This approach lets you keep a record like ssh.osztromok.com on your own domain pointing at your real home IP.

Step 1 — Create a Cloudflare API token

Go to Cloudflare dashboard → My Profile → API Tokens → Create Token. Use the Edit zone DNS template:

  • Permissions: Zone → DNS → Edit
  • Zone Resources: Include → Specific zone → osztromok.com
  • Click Continue and Create Token. Copy the token — it's only shown once.
Use an API Token, not the Global API Key. The Global API Key has full account access. The scoped token above only permits editing DNS for osztromok.com — if it ever leaks, the damage is limited.

Step 2 — Create the DNS record to be updated

In Cloudflare DNS, add an A record for the SSH hostname:

TypeNameContentProxyTTL
Assh82.2.236.221 (current IP)Grey cloud (DNS only)60 seconds

Grey cloud (DNS only) is essential here — this record needs to expose the real IP so SSH clients can connect directly. The orange cloud would hide the IP behind Cloudflare, which breaks SSH. TTL 60 seconds means the record updates are visible within a minute.

Step 3 — Install ddclient

$ sudo apt update && sudo apt install ddclient -y # The installer may show a configuration wizard — skip it (press Cancel or Esc) # We'll write the config file manually

Step 4 — Write the ddclient config

# /etc/ddclient.conf — Cloudflare provider ## How often to check for IP change (seconds) daemon=300 # check every 5 minutes syslog=yes # log to syslog (journalctl -u ddclient) pid=/var/run/ddclient.pid ## How to detect the current public IP use=web web=api.ipify.org # external service that returns plain IP text ## Cloudflare provider settings protocol=cloudflare zone=osztromok.com # your Cloudflare zone ttl=1 # TTL 1 = Cloudflare's minimum (auto = 60s) ## Cloudflare authentication — use the scoped API token login=token password=your-cloudflare-api-token-here ## DNS record(s) to update — one per line ssh.osztromok.com # updates this A record with current IP
# Lock down the config file — it contains your API token $ sudo chmod 600 /etc/ddclient.conf $ sudo chown root:root /etc/ddclient.conf # Test the config before starting the service $ sudo ddclient -daemon=0 -debug -verbose -noquiet DEBUG: CONNECT: api.ipify.org DEBUG: SENDING: GET / HTTP/1.0 DEBUG: RECEIVE: 82.2.236.221 SUCCESS: ssh.osztromok.com: updated successfully to 82.2.236.221 # Enable and start the service $ sudo systemctl enable ddclient $ sudo systemctl start ddclient $ sudo systemctl status ddclient ● ddclient.service Active: active (running) # Watch the logs live $ sudo journalctl -u ddclient -f
Update multiple records. If you want to keep more than one record updated, list them one per line at the bottom of ddclient.conf — all using the same zone and token settings above. For example, to also update home.osztromok.com, add it on a new line after ssh.osztromok.com.

Alternative — Pure Bash Script via Cloudflare API

If you'd rather not install ddclient, the same result is achievable with a short bash script and cron. This is useful if you want full visibility into what's happening or want to add custom logic (notifications, logging).

#!/bin/bash # /usr/local/bin/cf-ddns.sh — update Cloudflare A record with current IP API_TOKEN="your-cloudflare-api-token" ZONE_ID="your-zone-id" # Cloudflare dashboard → Overview → Zone ID (right sidebar) RECORD_NAME="ssh.osztromok.com" STATE_FILE="/var/cache/cf-ddns-last-ip" # Get current public IP CURRENT_IP=$(curl -s https://api.ipify.org) # Read last known IP LAST_IP=$(cat "$STATE_FILE" 2>/dev/null) # Exit early if IP hasn't changed — avoids unnecessary API calls if [ "$CURRENT_IP" = "$LAST_IP" ]; then exit 0 fi # Get the record ID for the A record we want to update RECORD_ID=$(curl -s -X GET \ "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?type=A&name=${RECORD_NAME}" \ -H "Authorization: Bearer ${API_TOKEN}" \ -H "Content-Type: application/json" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])") # Update the record RESULT=$(curl -s -X PATCH \ "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \ -H "Authorization: Bearer ${API_TOKEN}" \ -H "Content-Type: application/json" \ --data "{\"content\":\"${CURRENT_IP}\"}") # Save the new IP so we don't update again unnecessarily echo "$CURRENT_IP" > "$STATE_FILE" echo "$(date): Updated ${RECORD_NAME} → ${CURRENT_IP}"
$ sudo chmod +x /usr/local/bin/cf-ddns.sh $ sudo cf-ddns.sh # test run Sun Jun 14 21:30:00 BST 2026: Updated ssh.osztromok.com → 82.2.236.221 # Schedule with cron (every 5 minutes) $ sudo crontab -e # Add: */5 * * * * /usr/local/bin/cf-ddns.sh >> /var/log/cf-ddns.log 2>&1

Systemd Timer — Modern Alternative to Cron

Systemd timers are the modern replacement for cron on Debian/Ubuntu systems. They have better logging (integrated with journald), dependency support, and can be randomised to avoid thundering-herd problems on servers that all update at exactly the same second.

# /etc/systemd/system/cf-ddns.service [Unit] Description=Cloudflare DDNS update After=network-online.target # don't run before network is ready [Service] Type=oneshot ExecStart=/usr/local/bin/cf-ddns.sh
# /etc/systemd/system/cf-ddns.timer [Unit] Description=Run Cloudflare DDNS update every 5 minutes [Timer] OnBootSec=2min # run 2 minutes after boot OnUnitActiveSec=5min # then every 5 minutes RandomizedDelaySec=30s # spread load: run anywhere in a 30s window [Install] WantedBy=timers.target
$ sudo systemctl daemon-reload $ sudo systemctl enable cf-ddns.timer $ sudo systemctl start cf-ddns.timer # Check the timer status and when it will next fire $ sudo systemctl list-timers cf-ddns.timer NEXT LEFT LAST PASSED UNIT ACTIVATES Sun 2026-06-14 21:35:22 BST 4min Sun 2026-06-14 21:30:00 BST 22s cf-ddns.timer cf-ddns.service # View logs for past runs $ sudo journalctl -u cf-ddns.service --since today

Alerting When Your IP Changes

Worth adding a notification so you know when an IP change happens — useful for auditing that DDNS actually worked and for spotting unexpected IP changes (which might indicate a router issue).

# Add to cf-ddns.sh after the state file write, to send an email alert # Send email via msmtp (or any installed mail sender) echo "IP changed from ${LAST_IP:-unknown} to ${CURRENT_IP} — dns updated" \ | mail -s "DDNS update: ${RECORD_NAME}" emubantam@gmail.com # Or log to a file with timestamp for manual review echo "$(date '+%Y-%m-%d %H:%M:%S') IP changed: ${LAST_IP:-unknown} → ${CURRENT_IP}" \ >> /var/log/ip-changes.log
# View the IP change history $ cat /var/log/ip-changes.log 2026-04-10 03:22:14 IP changed: 82.2.236.221 → 82.3.45.67 2026-05-20 07:41:03 IP changed: 82.3.45.67 → 82.2.236.221 # Virgin Media sometimes reassigns the same IP you had before

Troubleshooting

ddclient runs but DNS record hasn't updated — still shows old IP
Run ddclient in debug mode to see exactly what's happening: sudo ddclient -daemon=0 -debug -verbose -noquiet. Common causes:
Auth failure: "login" is wrong — in ddclient.conf, login=token is literal text (not your email), password= is the API token value.
Wrong zone: the zone= value must exactly match your Cloudflare zone name.
Record not found: the hostname at the bottom of the config must match an existing DNS record in Cloudflare. Create the A record manually first, then ddclient will update it.
DuckDNS script returns "KO" instead of "OK"
The token or domain name is wrong. Double-check on the DuckDNS dashboard — the token is the long string under your account name. The domain should be just the subdomain part (e.g. osztromok, not osztromok.duckdns.org). Test manually: curl "https://www.duckdns.org/update?domains=YOURDOMAIN&token=YOURTOKEN&ip="
IP updated in Cloudflare but DNS still resolves to old IP
TTL caching. Even though Cloudflare updated the record, resolvers that cached the old answer continue to serve it until the TTL expires. Check: dig ssh.osztromok.com +short — if the record is grey cloud (DNS only), Cloudflare should apply the TTL you set (60 seconds is the minimum). If it's proxied/orange cloud, Cloudflare caches differently. Wait the TTL interval and dig again.
Cron job not running — script works manually but not on schedule
Cron's environment is minimal — it may not have the same PATH as your interactive session. Use absolute paths in cron scripts (/usr/bin/curl not curl). Check cron's own log: grep CRON /var/log/syslog | tail -20. Also verify the script is executable (chmod +x) and that the crontab line has no typos in the timing fields.
Cloudflare tunnel reconnects fine after IP change but SSH access breaks
This is the expected split: the tunnel (web traffic) uses an outbound connection that reconnects automatically. SSH access via ssh.osztromok.com uses a DNS A record that points to your real IP — this record needs DDNS to stay current. Verify DDNS is running: sudo systemctl status ddclient or check the state file: cat /var/cache/cf-ddns-last-ip and compare against curl -s https://api.ipify.org.

Quick Reference — Chapter 7

CommandPurpose
curl -s https://api.ipify.orgGet your current public IP from the server
dig +short ssh.osztromok.comCheck what IP is currently in the DNS record
sudo ddclient -daemon=0 -debug -verbose -noquietRun ddclient once in foreground with full debug output — best troubleshooting tool
sudo journalctl -u ddclient -fWatch ddclient daemon logs live
sudo journalctl -u cf-ddns.service --since todayView today's systemd timer runs for the custom script
sudo systemctl list-timersShow all timers and when they last/next ran
crontab -lList current user's cron jobs
grep CRON /var/log/syslog | tail -20Check whether cron ran recently (and if it logged errors)
ScenarioDDNS needed?Best approach
Website via Cloudflare TunnelNoTunnel handles it — no home IP in DNS
SSH into home server from outsideYesA record (grey cloud, TTL 60s) + ddclient updating it
Direct connection (no tunnel)YesA record + ddclient or DuckDNS
Just want to know your current IPNocurl -s https://api.ipify.org
File / LocationPurpose
/etc/ddclient.confddclient configuration — provider, token, records to update. Chmod 600.
/var/cache/ddclient.cacheddclient's record of the last IP it sent — compared on each check
/var/cache/cf-ddns-last-ipState file for the custom bash script approach
~/duckdns/duck.logDuckDNS update response log (OK or KO)
/var/log/ip-changes.logCustom IP change history log