Cloudflare

CF provides an excellent DDNS service and all you need is curl. Pretty simple, right?

ZONE_ID=
RECORD_ID=
TOKEN=

IP=$(curl -4 --silent --show-error --fail --max-time 10 https://api.ipify.org)

curl --silent --show-error --fail --max-time 10 \
                -X PATCH \
                "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
                -H "Authorization: Bearer $TOKEN" \
                -H "Content-Type: application/json" \
                --data "{\"content\":\"$IP\"}")

Who needs ddclient!

Noice! Done!

OK - well, you need to know your IDs, and you have to set up a token that only has DNS rights to the specific zone in question. And you need to handle errors, so I guess you need something more like…

vi ~/ddns-update
#!/bin/bash

set -euo pipefail

# Configuration
ZONE="your.org"
RECORD="server.your.org"
TOKEN="XXXXXXX"

# State dirs (systemd uses /var/lib/SERVICENAME)
STATE_DIR="/var/lib/cloudflare-ddns"
ZONE_ID_FILE="$STATE_DIR/zone_id"
RECORD_ID_FILE="$STATE_DIR/record_id"
LAST_IP_FILE="$STATE_DIR/last_ip"

# Load or fetch zone and record IDs needed for CF's API
if [ -f "$ZONE_ID_FILE" ]; then

    ZONE_ID=$(<"$ZONE_ID_FILE")

else

    ZONE_ID=$(curl --silent --show-error --fail --max-time 10 \
        -H "Authorization: Bearer $TOKEN" \
        -H "Content-Type: application/json" \
        "https://api.cloudflare.com/client/v4/zones?name=$ZONE" \
        | jq -r '.result[0].id')

    echo "$ZONE_ID" > "$ZONE_ID_FILE"
fi

[ -n "$ZONE_ID" ] && [ "$ZONE_ID" != "null" ] || {
    echo "Failed to discover Cloudflare Zone ID"
    exit 1
    }


if [ -f "$RECORD_ID_FILE" ]; then

    RECORD_ID=$(<"$RECORD_ID_FILE")

else

    RECORD_ID=$(curl --silent --show-error --fail --max-time 10 \
        -H "Authorization: Bearer $TOKEN" \
        -H "Content-Type: application/json" \
        "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=A&name=$RECORD" \
        | jq -r '.result[0].id')

    echo "$RECORD_ID" > "$RECORD_ID_FILE"
fi
    
[ -n "$RECORD_ID" ] && [ "$RECORD_ID" != "null" ] || {
    echo "Failed to discover Cloudflare Record ID"
    exit 1
    }
 

# Load the last IP. 
LAST_IP=""
if [ -f "$LAST_IP_FILE" ]; then

    LAST_IP=$(<"$LAST_IP_FILE")
fi

# Get the current IP
IP=$(curl -4 --silent --show-error --fail --max-time 10 https://api.ipify.org)
[[ $IP =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || {
    echo "Failed validation check for data: $IP" 
    exit 1
    }

# If they aren't the same
if [ "$IP" != "$LAST_IP" ]; then

        echo "Updating IP to $IP"

        RESPONSE=$(curl --silent --show-error --fail --max-time 10 \
                -X PATCH \
                "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
                -H "Authorization: Bearer $TOKEN" \
                -H "Content-Type: application/json" \
                --data "{\"content\":\"$IP\"}")
    

        RESULT=$(echo "$RESPONSE" | jq -r '.success')

        if [ "$RESULT" = "true" ]; then
                echo "$IP" > "$LAST_IP_FILE"
                echo "Update successful"
        else
                echo "Cloudflare update failed"
                echo "$RESPONSE" | jq -r '.errors[]?.message'
                exit 1
        fi
fi

Whew, OK. Now you need to get this running somehow. Cron? Oh, right. Not in Debian 13 by default. Systemd timer it is, then.

sudo install -m 700 ddns-update /usr/local/sbin/ddns-update
sudo vi /etc/systemd/system/cloudflare-ddns.service
[Unit]
Description=Cloudflare DDNS Update
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot

StateDirectory=cloudflare-ddns
StateDirectoryMode=0700

ExecStart=/usr/local/sbin/ddns-update
sudo vi /etc/systemd/system/cloudflare-ddns.timer
[Unit]
Description=Run Cloudflare DDNS updater every 15 minutes
LogLevelMax=warning

[Timer]
OnBootSec=1min
OnUnitActiveSec=15min

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now cloudflare-ddns.timer

Queue Spongebob: Two hours later…and we’ve just made a (worse) copy of ddclient that does (a lot) less.


Last modified May 5, 2026: Reduced log level (815fa34)