If you self-host anything at home (a media server, a VPN endpoint, a development environment), you’ve probably hit this problem: your ISP changes your public IP address, and suddenly nothing is reachable from the outside.
Commercial DDNS services like No-IP or DynDNS solve this, but they come with limitations: subdomains you don’t own, forced renewals, or paid tiers for basic features. If you already own a domain and use a DNS provider with an API (like Cloudflare), you can build your own DDNS in about 20 lines of bash.
Here’s how I did it, and how you can too.
How Dynamic DNS works
The concept is straightforward:
- A script periodically checks your current public IP address.
- If the IP has changed since the last check, it updates the DNS A record via your provider’s API.
- A cron job (or systemd timer) runs this script every few minutes.
That’s it. No agents, no third-party accounts, no dependencies beyond curl and jq.
┌──────────────┐ check IP ┌─────────────────┐
│ │ ───────────────► │ │
│ Cron Job │ │ Public IP API │
│ (every 5m) │ ◄─────────────── │ (ipify, etc.) │
│ │ current IP │ │
└──────┬───────┘ └─────────────────┘
│
│ IP changed?
│
▼
┌──────────────┐ API call ┌─────────────────┐
│ │ ───────────────► │ │
│ Update │ │ DNS Provider │
│ Script │ ◄─────────────── │ (Cloudflare) │
│ │ success/fail │ │
└──────────────┘ └─────────────────┘
Prerequisites
- A registered domain (e.g.,
example.com). - A DNS provider with API access. I use Cloudflare (free tier works fine), but this approach works with any provider that has a REST API, such as Route 53, DigitalOcean DNS, or Hetzner.
- An A record already created for the hostname you want to keep updated (e.g.,
home.example.com). - A Linux box at home that’s always on (a Raspberry Pi works perfectly).
Step 1: Get your DNS provider credentials
For Cloudflare, you’ll need three things:
- API Token. Create one in the Cloudflare dashboard under My Profile → API Tokens. Give it
Zone.DNSedit permissions for the relevant zone. - Zone ID. Found on your domain’s overview page in Cloudflare.
- Record ID. You can fetch this via the API:
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=home.example.com" \
-H "Authorization: Bearer $API_TOKEN" | jq '.result[0].id'
Step 2: The update script
Here’s the script I use. It checks the current IP, compares it to the last known IP, and only makes an API call if something changed.
#!/bin/bash
set -euo pipefail
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/ddns"
IP_FILE="$CONFIG_DIR/last_ip"
LOG_FILE="$CONFIG_DIR/ddns.log"
ZONE_ID="your_zone_id"
RECORD_ID="your_record_id"
API_TOKEN="your_api_token"
RECORD_NAME="home.example.com"
mkdir -p "$CONFIG_DIR"
CURRENT_IP=$(curl -sf https://api.ipify.org || curl -sf https://ifconfig.me)
if [ -z "$CURRENT_IP" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: failed to resolve public IP" >> "$LOG_FILE"
exit 1
fi
LAST_IP=""
[ -f "$IP_FILE" ] && LAST_IP=$(cat "$IP_FILE")
if [ "$CURRENT_IP" = "$LAST_IP" ]; then
exit 0
fi
RESPONSE=$(curl -sf -X PUT \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$RECORD_NAME\",\"content\":\"$CURRENT_IP\",\"ttl\":120,\"proxied\":false}")
if echo "$RESPONSE" | jq -e '.success' > /dev/null 2>&1; then
echo "$CURRENT_IP" > "$IP_FILE"
echo "$(date '+%Y-%m-%d %H:%M:%S') OK: updated $RECORD_NAME to $CURRENT_IP" >> "$LOG_FILE"
else
echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: API call failed - $RESPONSE" >> "$LOG_FILE"
exit 1
fi
A few things worth noting:
- Fallback IP resolution. If
ipifyis down, the script falls back toifconfig.me. - Change detection. The last known IP is cached in a file. No API calls are made unless the IP actually changed, so you’re not hammering Cloudflare’s rate limits.
- Logging. Every change and every error gets logged with a timestamp. Useful for debugging and for confirming the script is working.
set -euo pipefail. Fail fast on errors. You don’t want a half-broken script silently running for weeks.
Step 3: Automate with cron
Make the script executable and schedule it:
chmod +x /usr/local/bin/ddns-update.sh
Add a cron entry to run every 5 minutes:
crontab -e
*/5 * * * * /usr/local/bin/ddns-update.sh
Five minutes is a good balance. Most ISPs don’t change your IP more than once every few hours (if at all), so checking every 5 minutes catches changes quickly without being excessive.
Alternative: systemd timer
If you prefer systemd over cron (better logging integration, dependency management), create a service and timer:
# /etc/systemd/system/ddns-update.service
[Unit]
Description=Update DDNS record
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ddns-update.sh
User=nobody
# /etc/systemd/system/ddns-update.timer
[Unit]
Description=Run DDNS update every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
Enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now ddns-update.timer
Step 4: Verify it works
Run the script manually first:
/usr/local/bin/ddns-update.sh
Then confirm the DNS record updated:
dig +short home.example.com
The output should match your current public IP. If your TTL is set to 120 seconds, allow a couple of minutes for propagation.
Security considerations
- Never hardcode API tokens in the script for production use. Store them in a separate file with restricted permissions (
chmod 600) and source it from the script, or use environment variables. - Restrict the API token’s scope. On Cloudflare, create a token that can only edit DNS for the specific zone, not your entire account.
- Log rotation. If this runs for months, the log file will grow. Either rotate it with
logrotateor add a size check in the script.
IPv6 support
If your ISP assigns you a public IPv6 address, you can update an AAAA record the same way. Just change the IP detection method:
CURRENT_IPV6=$(curl -sf https://api6.ipify.org)
And update the API call to use "type": "AAAA" instead of "type": "A".
Alternative: DuckDNS (simpler, less control)
If you don’t own a domain and just need something quick, DuckDNS is free and dead simple:
#!/bin/bash
DOMAIN="yourdomain"
TOKEN="yourtoken"
curl -s "https://www.duckdns.org/update?domains=$DOMAIN&token=$TOKEN&ip="
DuckDNS auto-detects your IP if you leave the ip= parameter empty. The trade-off is you’re stuck with a *.duckdns.org subdomain and you depend on their service being available.
Wrapping up
Building your own DDNS is one of those small projects that pays off for years. It takes 15 minutes to set up, costs nothing, and removes a dependency on third-party services. Once it’s running, you can reliably reach your home network from anywhere, whether it’s for SSH access, a WireGuard VPN, or a self-hosted service.