Setting Up a Cloudflare Dynamic DNS on Debian with systemd
A homemade DDNS on Debian: a Bash script, the Cloudflare API and a systemd timer to keep an A record in sync with a dynamic public IP.
Also available in Français

When you use a laptop or a server behind an Internet connection with a dynamic public IPv4 address, the address can change at any time.
The goal is simple:
arthur-perrot.example.com
↓
Current public IPv4 addressThe machine regularly checks its public IP and, when it changes, automatically updates the matching DNS record at Cloudflare.
In this guide, we will set up this DDNS with a simple Bash script, the Cloudflare API and a systemd timer on Debian.
Prerequisites
You need:
- a domain whose DNS is managed by Cloudflare;
- Debian or another distribution using systemd;
curl;jq;- a Cloudflare API token.
Installing the dependencies:
sudo apt update
sudo apt install curl jqIn the examples, we will use:
DNS zone : example.com
Record : arthur-perrot.example.com1. Create the Cloudflare API token
From Cloudflare, create an API Token with only the permissions required on the relevant zone.
Recommended permissions:
Zone → DNS → Edit
Zone → Zone → ReadIt is better to limit the token to a single DNS zone rather than giving it access to the whole account.
You can check that the token works:
export CF_API_TOKEN='cfat_xxxxxxxxxxxxxxxxx'
curl -s \
-H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=example.com" | jqCloudflare should return, among other things:
{
"success": true
}The response also contains the zone identifier:
"id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Keep this Zone ID, we will use it directly in the script.
2. Create the DNS record
In Cloudflare, create an A record, for example:
Type : A
Name : arthur-perrot
IPv4 : 1.2.3.4
TTL : AutoThe initial address does not matter: the script will replace it on its first run.
You can check the resolution with:
dig +short arthur-perrot.example.com @1.1.1.1For example:
1.2.3.43. Create the DDNS script
Let’s create our script:
sudo nano /usr/local/bin/cloudflare-ddnsContent:
#!/bin/bash
set -e
TOKEN="$CF_API_TOKEN"
ZONE_ID="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
RECORD="arthur-perrot.example.com"
# Fetch the public IPv4 address
IP=$(curl -fsS -4 https://api.ipify.org)
# Look up the DNS record identifier
RECORD_ID=$(curl -fsS \
-H "Authorization: Bearer $TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=A&name=$RECORD" \
| jq -r '.result[0].id')
if [ "$RECORD_ID" = "null" ] || [ -z "$RECORD_ID" ]; then
echo "Record $RECORD introuvable"
exit 1
fi
# Fetch the address currently published
CURRENT=$(curl -fsS \
-H "Authorization: Bearer $TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
| jq -r '.result.content')
# Nothing to do if the address has not changed
if [ "$CURRENT" = "$IP" ]; then
echo "IP inchangée : $IP"
exit 0
fi
# Update the Cloudflare record
curl -fsS -X PATCH \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
--data "{\"content\":\"$IP\"}" \
| jq -e '.success' >/dev/null
echo "$RECORD : $CURRENT -> $IP"Make the script executable:
sudo chmod 700 /usr/local/bin/cloudflare-ddnsThe logic is intentionally simple:
Fetch the public IP
↓
Read the Cloudflare record
↓
Same IP?
↙ ↘
Yes No
↓ ↓
Do nothing Update
Cloudflare4. Store the token separately
It is better not to put the Cloudflare token directly in the script.
Create:
sudo nano /etc/cloudflare-ddns.envWith:
CF_API_TOKEN=cfat_xxxxxxxxxxxxxxxxxThen restrict the permissions:
sudo chown root:root /etc/cloudflare-ddns.env
sudo chmod 600 /etc/cloudflare-ddns.envThis way, only root can read the token.
5. Create the systemd service
Create:
sudo nano /etc/systemd/system/cloudflare-ddns.serviceWith:
[Unit]
Description=Cloudflare DDNS updater
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/cloudflare-ddns.env
ExecStart=/usr/local/bin/cloudflare-ddnsThen reload systemd:
sudo systemctl daemon-reloadLet’s test the service manually:
sudo systemctl start cloudflare-ddnsThen:
systemctl status cloudflare-ddnsA result like this one indicates that everything works:
Starting cloudflare-ddns.service - Cloudflare DDNS updater...
arthur-perrot.example.com : 1.2.3.4 -> 203.0.113.42
Deactivated successfully.
Finished cloudflare-ddns.service - Cloudflare DDNS updater.On subsequent runs, if the IP has not changed:
IP inchangée : 203.0.113.42It is normal for the service to then show as:
inactive (dead)This is a oneshot service: it does its job and then exits.
6. Run the DDNS automatically every 5 minutes
We can now use a systemd timer.
Create:
sudo nano /etc/systemd/system/cloudflare-ddns.timerWith:
[Unit]
Description=Update Cloudflare DDNS every 5 minutes
[Timer]
OnBootSec=30s
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.targetEnable the timer:
sudo systemctl daemon-reload
sudo systemctl enable --now cloudflare-ddns.timerCheck its status:
systemctl status cloudflare-ddns.timerYou should get:
Active: active (waiting)To see the next run:
systemctl list-timers cloudflare-ddns.timerFor example:
NEXT LEFT UNIT
Tue 17:43:20 EDT 4min cloudflare-ddns.timer7. Check the logs
Script runs are recorded in the systemd journal.
Show the latest runs:
journalctl -u cloudflare-ddns.service -n 20Follow the next runs live:
journalctl -fu cloudflare-ddns.serviceTo show only the events since the last boot:
journalctl -b -u cloudflare-ddns.service8. Check after a reboot
The timer was enabled with:
sudo systemctl enable --now cloudflare-ddns.timerso it will start automatically with Debian.
You can check:
systemctl is-enabled cloudflare-ddns.timer
systemctl is-active cloudflare-ddns.timerExpected result:
enabled
activeAbout 30 seconds after boot, a first check will run. After that, the script will run every five minutes.
9. Check the result
Show the public IPv4 address:
curl -4 -s https://api.ipify.org
echoThen check the DNS:
dig +short arthur-perrot.example.com @1.1.1.1Both values must match:
203.0.113.42
203.0.113.42The final flow is therefore:
┌─────────────────┐
│ Debian PC │
└────────┬────────┘
│
every 5 min
│
▼
Current public IP
│
▼
Compare with Cloudflare
│
┌────────┴────────┐
│ │
same different
│ │
▼ ▼
nothing Cloudflare API
│
▼
arthur-perrot.example.com
│
▼
new public IPv4The advantage of this solution is that it does not require any extra DDNS daemon: only curl, jq, a few lines of Bash and the native systemd mechanisms.
It is particularly suitable for a laptop that often changes networks: home, phone hotspot, public Wi-Fi, etc.
Keep in mind, however, that DDNS does not bypass NAT or network restrictions. The DNS name will point to the public IP address used by the laptop, but this does not guarantee that incoming connections to it are possible, especially behind CGNAT or on a network that blocks incoming connections.