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:


Table of Contents

  1. Context / Why This Matters
  2. Design of the Updater
  3. The Script
  4. Scheduling: Timer vs Cron
  5. Verification
  6. Troubleshooting & Common Pitfalls
  7. Next Steps / Ops Actions
  8. Sources & Related
  9. 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:

  1. Idempotent โ€” running every 5 minutes must not create duplicate records.
  2. Change-only writes โ€” compare current IP to record content; call API only on drift.
  3. Least-privilege token โ€” scoped exactly like ddns-updater in cloudflare-dns.
  4. DNS-only record โ€” grey-cloud, since WireGuard is UDP and CF can't proxy it.
  5. 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

Sources & Related

External references consulted:

Related knowledge-base articles:

Change Log

2026-08-26

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse