Dynamic DNS (DDNS) Configuration

Dynamic DNS automatically updates DNS records when a server's IP address changes, ensuring services remain reachable despite ISP-assigned dynamic IP addresses. This guide covers configuring DDNS with BIND's nsupdate, Cloudflare API updates, ddclient setup, and automated IP detection scripts.

Prerequisites

  • Ubuntu 22.04/Debian 12 or CentOS/Rocky 9
  • A registered domain name
  • A DNS server you control (BIND) or a DNS provider with an API (Cloudflare, etc.)
  • BIND 9 for nsupdate method, or Cloudflare account for API method
  • curl, jq installed for API-based scripts

How Dynamic DNS Works

  1. Your server detects its current public IP address
  2. It compares the IP to the current DNS record
  3. If they differ, it sends an authenticated update to the DNS server
  4. The DNS record is updated, and the TTL on the record is set low (60-300 seconds) to minimize propagation delay

Dynamic DNS with BIND and nsupdate

BIND supports RFC 2136 dynamic DNS updates via nsupdate. Updates are authenticated with TSIG keys.

Generate a TSIG key:

# Install BIND utilities
sudo apt install -y bind9utils   # Ubuntu
sudo dnf install -y bind-utils   # CentOS

# Generate a TSIG key for DDNS updates
tsig-keygen -a hmac-sha256 ddns-key

# Example output (save this):
# key "ddns-key" {
#     algorithm hmac-sha256;
#     secret "base64encodedkeyhere=";
# };

Configure BIND to accept DDNS updates:

Add the key to your BIND configuration:

sudo tee /etc/bind/ddns-key.conf << 'EOF'
key "ddns-key" {
    algorithm hmac-sha256;
    secret "YOUR_BASE64_KEY_HERE=";
};
EOF

sudo chmod 640 /etc/bind/ddns-key.conf
sudo chown root:bind /etc/bind/ddns-key.conf

Update the zone configuration in named.conf to allow updates with this key:

zone "example.com" {
    type master;
    file "/var/cache/bind/example.com.db";
    update-policy {
        grant ddns-key zonesub ANY;
        # Or restrict to specific records:
        # grant ddns-key name home.example.com A AAAA;
    };
};

Use nsupdate to update a record:

# Create the nsupdate script
cat > /usr/local/bin/ddns-update.sh << 'SCRIPT'
#!/bin/bash
# Get current public IP
CURRENT_IP=$(curl -s https://api.ipify.org)
HOSTNAME="home.example.com"
DNS_SERVER="ns1.example.com"
KEY_FILE="/etc/bind/ddns-key.conf"
TTL=300

# Update the DNS record
nsupdate -k ${KEY_FILE} << EOF
server ${DNS_SERVER}
zone example.com
update delete ${HOSTNAME} A
update add ${HOSTNAME} ${TTL} A ${CURRENT_IP}
send
quit
EOF

echo "Updated ${HOSTNAME} to ${CURRENT_IP}"
SCRIPT

chmod +x /usr/local/bin/ddns-update.sh

# Test the update
sudo /usr/local/bin/ddns-update.sh

# Verify the update
dig @ns1.example.com home.example.com

Set up the BIND zone file for DDNS:

When using DDNS, BIND manages the zone file automatically. Create an initial zone file:

sudo tee /var/cache/bind/example.com.db << 'EOF'
$ORIGIN example.com.
$TTL 3600

@   IN  SOA ns1.example.com. admin.example.com. (
            2026040401 3600 900 604800 300 )

@       IN  NS  ns1.example.com.
ns1     IN  A   203.0.113.1

; DDNS-managed record (will be updated dynamically)
home    IN  A   0.0.0.0
EOF

sudo chown bind:bind /var/cache/bind/example.com.db
sudo systemctl restart bind9

Dynamic DNS with Cloudflare API

Cloudflare's API allows programmatic DNS record updates with no BIND server needed:

# Install required tools
sudo apt install -y curl jq

# Set your Cloudflare credentials
CF_EMAIL="[email protected]"
CF_API_TOKEN="your_cloudflare_api_token"  # Create at dash.cloudflare.com/profile/api-tokens
CF_ZONE_ID="your_zone_id"                  # Found on domain overview page
RECORD_NAME="home.example.com"

# Get the record ID first (run once)
curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records?type=A&name=${RECORD_NAME}" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" | jq '.result[0].id'

# Create an A record if it doesn't exist
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "type": "A",
    "name": "home.example.com",
    "content": "1.2.3.4",
    "ttl": 120,
    "proxied": false
  }'

Configure ddclient for Automatic Updates

ddclient is a Perl daemon that polls your IP and updates DDNS providers automatically:

# Install ddclient
sudo apt install -y ddclient    # Ubuntu
sudo dnf install -y ddclient    # CentOS

Configure ddclient for Cloudflare:

sudo tee /etc/ddclient.conf << 'EOF'
# ddclient configuration for Cloudflare DDNS

# Global settings
daemon=300          # Check every 5 minutes
ssl=yes
use=web, web=https://api.ipify.org/

# Cloudflare
protocol=cloudflare
zone=example.com
[email protected]
password=YOUR_CLOUDFLARE_API_TOKEN
ttl=120
home.example.com, vpn.example.com
EOF

sudo chmod 600 /etc/ddclient.conf

Configure ddclient for Namecheap:

sudo tee /etc/ddclient.conf << 'EOF'
daemon=300
ssl=yes
use=web, web=dynamicdns.park-your-domain.com/getip

protocol=namecheap
server=dynamicdns.park-your-domain.com
login=example.com
password=YOUR_DDNS_PASSWORD
home
EOF

Configure ddclient for a custom BIND server:

sudo tee /etc/ddclient.conf << 'EOF'
daemon=300
ssl=no
use=web, web=https://api.ipify.org/

protocol=nsupdate
server=ns1.example.com
password=/etc/bind/ddns-key.conf
zone=example.com
home.example.com
EOF

Enable and start ddclient:

sudo systemctl enable --now ddclient

# Test configuration
sudo ddclient -daemon=0 -debug -verbose -noquiet

# Check status
sudo ddclient -daemon=0 -query

Automated IP Detection Script

A reusable function for detecting the current public IP:

cat > /usr/local/lib/get-public-ip.sh << 'EOF'
#!/bin/bash
# Try multiple sources for redundancy
get_public_ip() {
    local ip

    # Try multiple IP detection services
    for url in \
        "https://api.ipify.org" \
        "https://checkip.amazonaws.com" \
        "https://icanhazip.com" \
        "https://ifconfig.me"; do
        ip=$(curl -s --connect-timeout 5 --max-time 10 "${url}" 2>/dev/null | tr -d '[:space:]')
        if [[ "${ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            echo "${ip}"
            return 0
        fi
    done

    echo "ERROR: Could not determine public IP" >&2
    return 1
}
EOF

DDNS with a Custom Bash Script

A complete DDNS update script using Cloudflare API with caching to avoid unnecessary updates:

sudo tee /usr/local/bin/cloudflare-ddns.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

# Configuration
CF_API_TOKEN="your_cloudflare_api_token"
CF_ZONE_ID="your_zone_id"
CF_RECORD_ID="your_dns_record_id"   # Get with API call above
RECORD_NAME="home.example.com"
IP_CACHE_FILE="/var/cache/ddns-ip.txt"
LOG_FILE="/var/log/ddns.log"

# Get current public IP
CURRENT_IP=$(curl -s --connect-timeout 10 https://api.ipify.org)

# Validate IP address format
if ! [[ "${CURRENT_IP}" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
    echo "$(date): ERROR - Invalid IP: ${CURRENT_IP}" >> "${LOG_FILE}"
    exit 1
fi

# Compare with cached IP (avoid unnecessary API calls)
CACHED_IP=$(cat "${IP_CACHE_FILE}" 2>/dev/null || echo "none")

if [[ "${CURRENT_IP}" == "${CACHED_IP}" ]]; then
    # IP hasn't changed, no update needed
    exit 0
fi

echo "$(date): IP changed from ${CACHED_IP} to ${CURRENT_IP} - updating ${RECORD_NAME}" >> "${LOG_FILE}"

# Update Cloudflare DNS record
RESPONSE=$(curl -s -X PUT \
  "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records/${CF_RECORD_ID}" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{
    \"type\": \"A\",
    \"name\": \"${RECORD_NAME}\",
    \"content\": \"${CURRENT_IP}\",
    \"ttl\": 120,
    \"proxied\": false
  }")

# Check if update was successful
SUCCESS=$(echo "${RESPONSE}" | jq -r '.success')
if [[ "${SUCCESS}" == "true" ]]; then
    echo "${CURRENT_IP}" > "${IP_CACHE_FILE}"
    echo "$(date): Successfully updated ${RECORD_NAME} to ${CURRENT_IP}" >> "${LOG_FILE}"
else
    echo "$(date): ERROR - API update failed: ${RESPONSE}" >> "${LOG_FILE}"
    exit 1
fi
SCRIPT

chmod +x /usr/local/bin/cloudflare-ddns.sh

# Test the script
sudo /usr/local/bin/cloudflare-ddns.sh
cat /var/log/ddns.log

# Add to cron to run every 5 minutes
echo "*/5 * * * * root /usr/local/bin/cloudflare-ddns.sh" | sudo tee /etc/cron.d/cloudflare-ddns

# Or as a systemd timer
sudo tee /etc/systemd/system/ddns-update.service << 'EOF'
[Unit]
Description=Dynamic DNS update

[Service]
Type=oneshot
ExecStart=/usr/local/bin/cloudflare-ddns.sh
EOF

sudo tee /etc/systemd/system/ddns-update.timer << 'EOF'
[Unit]
Description=Dynamic DNS update timer

[Timer]
OnBootSec=1min
OnUnitActiveSec=5min

[Install]
WantedBy=timers.target
EOF

sudo systemctl enable --now ddns-update.timer

Troubleshooting

nsupdate: update failed: REFUSED:

# Verify the key name and algorithm match in both the update command and named.conf
# Check update-policy in the zone configuration
# Check BIND logs:
journalctl -u named -n 50 --no-pager | grep -i "refused\|update"

ddclient not updating:

# Run in verbose debug mode
sudo ddclient -daemon=0 -debug -verbose -noquiet 2>&1 | tail -30

# Check status/cache file
cat /var/cache/ddclient.cache

Cloudflare API returning 9103 (incorrect zone ID):

# Get the correct zone ID
curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=example.com" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" | jq '.result[0].id'

DNS still returning old IP after update:

# Check TTL on the record (low TTL needed for fast propagation)
dig +ttl example.com

# Flush your local DNS cache
sudo systemd-resolve --flush-caches   # Ubuntu
sudo dscacheutil -flushcache          # macOS

Conclusion

Dynamic DNS is essential for running services on servers with dynamic IP addresses, whether home servers or cloud instances with changing IPs. The Cloudflare API approach is the simplest and most reliable for most use cases, while nsupdate with BIND gives you full control for self-hosted DNS. Use ddclient for a managed daemon solution, or deploy the custom Bash script with a systemd timer for a lightweight, maintainable alternative without extra dependencies.