Full journal
· 5 min read

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 address

The 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 jq

In the examples, we will use:

DNS zone : example.com
Record   : arthur-perrot.example.com

1. 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 → Read

It 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" | jq

Cloudflare 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  : Auto

The 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.1

For example:

1.2.3.4

3. Create the DDNS script

Let’s create our script:

sudo nano /usr/local/bin/cloudflare-ddns

Content:

#!/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-ddns

The logic is intentionally simple:

Fetch the public IP
              ↓
Read the Cloudflare record
              ↓
  Same IP?
        ↙           ↘
      Yes           No
       ↓             ↓
   Do nothing    Update
                 Cloudflare

4. Store the token separately

It is better not to put the Cloudflare token directly in the script.

Create:

sudo nano /etc/cloudflare-ddns.env

With:

CF_API_TOKEN=cfat_xxxxxxxxxxxxxxxxx

Then restrict the permissions:

sudo chown root:root /etc/cloudflare-ddns.env
sudo chmod 600 /etc/cloudflare-ddns.env

This way, only root can read the token.

5. Create the systemd service

Create:

sudo nano /etc/systemd/system/cloudflare-ddns.service

With:

[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-ddns

Then reload systemd:

sudo systemctl daemon-reload

Let’s test the service manually:

sudo systemctl start cloudflare-ddns

Then:

systemctl status cloudflare-ddns

A 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.42

It 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.timer

With:

[Unit]
Description=Update Cloudflare DDNS every 5 minutes

[Timer]
OnBootSec=30s
OnUnitActiveSec=5min
Persistent=true

[Install]
WantedBy=timers.target

Enable the timer:

sudo systemctl daemon-reload
sudo systemctl enable --now cloudflare-ddns.timer

Check its status:

systemctl status cloudflare-ddns.timer

You should get:

Active: active (waiting)

To see the next run:

systemctl list-timers cloudflare-ddns.timer

For example:

NEXT                        LEFT     UNIT
Tue 17:43:20 EDT            4min     cloudflare-ddns.timer

7. Check the logs

Script runs are recorded in the systemd journal.

Show the latest runs:

journalctl -u cloudflare-ddns.service -n 20

Follow the next runs live:

journalctl -fu cloudflare-ddns.service

To show only the events since the last boot:

journalctl -b -u cloudflare-ddns.service

8. Check after a reboot

The timer was enabled with:

sudo systemctl enable --now cloudflare-ddns.timer

so it will start automatically with Debian.

You can check:

systemctl is-enabled cloudflare-ddns.timer
systemctl is-active cloudflare-ddns.timer

Expected result:

enabled
active

About 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
echo

Then check the DNS:

dig +short arthur-perrot.example.com @1.1.1.1

Both values must match:

203.0.113.42
203.0.113.42

The 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 IPv4

The 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.

Subscribe via RSS

DRAWN BY A. PERROTRSS · © 2026 — RÉV. 11/2026