If you host something out in residential land you’ll need this. Your IP changes periodically and you don’t want to have track that manually.
1 - 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.