$ cd ~/posts/ddns

Homemade Dynamic DNS: Build Your Own DDNS Service

$ date → 28 November 2025

ddns · dns · networking · self-hosting · automation

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:

  1. A script periodically checks your current public IP address.
  2. If the IP has changed since the last check, it updates the DNS A record via your provider’s API.
  3. 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.DNS edit 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 ipify is down, the script falls back to ifconfig.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 logrotate or 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.

$ auth --required

Enter your email to receive a code and read the rest of this post.