Dynamic DNS - Keeping Records Fresh on a Changing Home IP
Status: Active
Last Updated: 2026-08-26
Category: Networking - DNS Automation
Prerequisites: cloudflare-dns, dns-explained
Time: 2 hours
Tags: ddns, cloudflare, cron, systemd-timer, home-ip, automation
Summary
Residential IPs change. This article covers detecting your current public address, updating the vpn.fogserv.cloud Cloudflare record automatically with a small API script under cron or a systemd timer, and choosing TTLs so clients converge quickly after an IP change.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Detect the public IPv4 (and optionally IPv6) reliably
- โ Run an idempotent Cloudflare DDNS updater via systemd timer
- โ Pick TTLs and verify convergence after a change
Table of Contents
- Context / Why This Matters
- Design of the Updater
- The Script
- Scheduling: Timer vs Cron
- Verification
- Troubleshooting & Common Pitfalls
- Next Steps / Ops Actions
- Sources & Related
- Change Log
Context / Why This Matters
Everything that connects into the homelab from outside โ WireGuard peers (wireguard-setup, ../security/wireguard-vpn) โ resolves vpn.fogserv.cloud to find us. When the ISP rotates the IP, every peer is dead until DNS says otherwise. A five-line updater closes that gap; a stale record discovered by users does not.
Design of the Updater
Requirements:
- Idempotent โ running every 5 minutes must not create duplicate records.
- Change-only writes โ compare current IP to record content; call API only on drift.
- Least-privilege token โ scoped exactly like
ddns-updaterin cloudflare-dns. - DNS-only record โ grey-cloud, since WireGuard is UDP and CF can't proxy it.
- Low TTL โ 60โ300s so peers re-resolve fast after a rotation.
The Script
/opt/ddns/update-ddns.sh:
#!/usr/bin/env bash
set -euo pipefail
CF_TOKEN="$(cat /etc/ddns/cf-token)" # 0600 root-owned
ZONE_ID="your_zone_id"
RECORD_NAME="vpn.fogserv.cloud"
TTL=120
CURRENT_IP="$(curl -fsS https://api.ipify.org)"
[ -n "$CURRENT_IP" ] || { echo "no public ip detected"; exit 1; }
API="https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records"
H=(-H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json")
REC_ID="$(curl -fsS "${H[@]}" "$API?type=A&name=$RECORD_NAME" | jq -r '.result[0].id // empty')"
OLD_IP="$(curl -fsS "${H[@]}" "$API?type=A&name=$RECORD_NAME" | jq -r '.result[0].content // empty')"
if [ "$CURRENT_IP" = "$OLD_IP" ]; then
echo "$(date -Is) no change ($CURRENT_IP)"; exit 0
fi
BODY=$(jq -n --arg ip "$CURRENT_IP" --arg n "$RECORD_NAME" --argjson ttl "$TTL" \
'{type:"A",name:$n,content:$ip,ttl:$ttl,proxied:false}')
if [ -n "$REC_ID" ]; then
curl -fsS -X PUT "${H[@]}" --data "$BODY" "$API/$REC_ID" >/dev/null
else
curl -fsS -X POST "${H[@]}" --data "$BODY" "$API" >/dev/null
fi
echo "$(date -Is) updated $RECORD_NAME: $OLD_IP -> $CURRENT_IP"
sudo install -m 755 update-ddns.sh /opt/ddns/
echo "TOKEN" | sudo tee /etc/ddns/cf-token && sudo chmod 600 /etc/ddns/cf-token
Scheduling: Timer vs Cron
Prefer a systemd timer (jitter, logging via journald, catch-up after downtime):
# /etc/systemd/system/ddns.service
[Unit]
Description=fogserv.cloud DDNS updater
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/ddns/update-ddns.sh
# /etc/systemd/system/ddns.timer
[Unit]
Description=Run DDNS updater every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
RandomizedDelaySec=30
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload && sudo systemctl enable --now ddns.timer
systemctl list-timers ddns.timer
Verification
journalctl -u ddns.service -n 20 # see periodic "no change" lines
# Force a test: temporarily set record content to 192.0.2.1, wait โค5 min:
dig +short vpn.fogserv.cloud @1.1.1.1 # should return your real IP within TTL
# End-to-end: connect a WireGuard peer after a forced rotation
IPv6 variant: same script with type=AAAA, detect via curl -6 https://api6.ipify.net.
Troubleshooting & Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Duplicate records accumulate | POST used when PUT was needed | Idempotent lookup-then-put as above; audit zone quarterly |
| Updater runs but record never changes | Token lacks DNS Edit scope, or wrong zone ID | tokens/verify; check API response body not just exit code |
| Detection returns router/CGNAT private IP | curl hit local resolver hijack | Use api.ipify.org over HTTPS; check for CGNAT (traceroute first hop โ public IP) |
| Peers still fail after update | Cached old answer + high TTL | Keep TTL โค300; restart WG handshakes or wait out TTL |
| Script dies silently for weeks | No alerting on failure | OnFailure= hook into alerts (../observability/simple-alerts) |
Next Steps / Ops Actions
- Point WireGuard endpoints at this name: wireguard-setup, ../security/wireguard-vpn
- Alert when the timer stops firing: monitoring-networks
- Review TTL policy: dns-explained
Sources & Related
External references consulted:
- https://developers.cloudflare.com/api/resources/dns/
- https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation: idempotent CF updater script, systemd timer, TTL guidance.