Dynamic DNS (DDNS) keeps a hostname such as home.example.com pointed at a machine whose public IP address changes, for example a home lab or office server on a residential connection. In this tutorial you will write a short, safe script that detects the current public IPv4 address and updates an A record through the Cloudflare API, then run it every five minutes with a systemd timer on Ubuntu 24.04. At the end you will also see how to do the same against your own BIND server with nsupdate and a TSIG key.
Prerequisites
To follow this tutorial you need:
- The machine with the changing IP address running Ubuntu 24.04 LTS, with a non-root user that has
sudoprivileges. - A domain whose DNS is hosted on Cloudflare (the free plan is enough). In the examples the domain is
example.comand the dynamic hostname ishome.example.com; replace both with your own. - Access to the Cloudflare dashboard to create an API token.
Servers with a static public IP, such as a CubePath VPS, do not need DDNS: create a normal A record once instead.
How the update works
Every run of the script does four things:
- Asks an external service which public IPv4 address the machine is using.
- Compares it with the address it sent last time (stored in a small state file).
- If the address changed, sends a
PATCHrequest to the Cloudflare API with the new value. - Saves the new address so the next runs do nothing until it changes again.
Keeping the record TTL low (60 seconds) means resolvers pick up the new address quickly after a change.
Step 1 - Creating the DNS record and an API token
Create the record the script will update. In the Cloudflare dashboard, open your domain, go to DNS > Records and add:
- Type:
A - Name:
home - IPv4 address: any value for now, for example
192.0.2.1 - Proxy status: DNS only (grey cloud), so the name resolves to your real IP
- TTL:
1 min
Next create an API token that can only edit DNS in this zone, so a leaked token cannot touch anything else in your account. Go to My Profile > API Tokens > Create Token, pick the Edit zone DNS template, and under Zone Resources select Include > Specific zone > example.com. Create the token and copy it; Cloudflare shows it only once.
Finally, copy the Zone ID from your domain's Overview page (right-hand column, API section).
Step 2 - Installing the tools and finding the record ID
The script needs curl to call the API and jq to build and parse JSON. dig is used to verify the result:
sudo apt update
sudo apt install -y curl jq bind9-dnsutils
The API updates records by ID, not by name. Load the token into your shell without echoing it to the screen or saving it in the shell history:
read -rsp 'Cloudflare API token: ' CF_API_TOKEN; echo
Set the zone ID you copied as a variable, replacing your_zone_id:
CF_ZONE_ID=your_zone_id
Check that the token is valid:
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
-H "Authorization: Bearer ${CF_API_TOKEN}" | jq '.success, .result.status'
true
"active"
Now look up the ID of the home.example.com record:
curl -s "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records?type=A&name=home.example.com" \
-H "Authorization: Bearer ${CF_API_TOKEN}" | jq -r '.result[0].id'
372e67954025e0ba6aaa6d586b9e0b59
If the output is null, the record name or zone ID does not match. Check both and run the command again.
Step 3 - Storing the credentials
Keep the token out of the script itself. Create an environment file that only root can read:
sudo install -m 600 -o root -g root /dev/null /etc/cloudflare-ddns.env
sudo nano /etc/cloudflare-ddns.env
Add the following lines, replacing the placeholders with your token, zone ID, record ID and hostname:
CF_API_TOKEN=your_api_token
CF_ZONE_ID=your_zone_id
CF_RECORD_ID=your_record_id
CF_RECORD_NAME=home.example.com
Save the file and clear the token from your interactive shell:
unset CF_API_TOKEN
Step 4 - Writing the update script
Create the script:
sudo nano /usr/local/bin/cloudflare-ddns
Paste the following content:
#!/usr/bin/env bash
set -euo pipefail
: "${CF_API_TOKEN:?CF_API_TOKEN is not set}"
: "${CF_ZONE_ID:?CF_ZONE_ID is not set}"
: "${CF_RECORD_ID:?CF_RECORD_ID is not set}"
: "${CF_RECORD_NAME:?CF_RECORD_NAME is not set}"
state_dir="${STATE_DIRECTORY:-/var/lib/cloudflare-ddns}"
cache_file="${state_dir}/last_ip"
api="https://api.cloudflare.com/client/v4"
current_ip="$(curl -4 -fsS --max-time 10 https://api.ipify.org)"
if [[ ! "${current_ip}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
echo "Could not detect a valid public IPv4 address (got: '${current_ip}')" >&2
exit 1
fi
last_ip="$(cat "${cache_file}" 2>/dev/null || true)"
if [[ "${current_ip}" == "${last_ip}" ]]; then
echo "IP unchanged (${current_ip}), nothing to do"
exit 0
fi
payload="$(jq -n --arg ip "${current_ip}" '{content: $ip}')"
if ! response="$(curl -sS --fail-with-body --max-time 15 -X PATCH \
"${api}/zones/${CF_ZONE_ID}/dns_records/${CF_RECORD_ID}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "${payload}")"; then
echo "Cloudflare API request failed: ${response}" >&2
exit 1
fi
if [[ "$(jq -r '.success' <<< "${response}")" != "true" ]]; then
echo "Cloudflare API error: $(jq -c '.errors' <<< "${response}")" >&2
exit 1
fi
printf '%s\n' "${current_ip}" > "${cache_file}"
echo "Updated ${CF_RECORD_NAME} to ${current_ip}"
A few details that matter:
curl -4forces IPv4, so the script does not detect an IPv6 address and try to write it into an A record.- The regular expression rejects error pages or empty answers from the IP service.
--fail-with-bodymakescurlreturn an error on HTTP 4xx/5xx while still keeping Cloudflare's JSON error message for the log.- The cached IP avoids calling the Cloudflare API every five minutes when nothing has changed.
Make the script executable:
sudo chmod 755 /usr/local/bin/cloudflare-ddns
Step 5 - Running the script with a systemd service and timer
A oneshot service runs the script with the credentials from the environment file. DynamicUser=yes runs it as a throwaway unprivileged user, and StateDirectory= gives it a writable directory for the cached IP.
sudo nano /etc/systemd/system/cloudflare-ddns.service
[Unit]
Description=Update Cloudflare DNS record with the current public IP
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/cloudflare-ddns.env
ExecStart=/usr/local/bin/cloudflare-ddns
DynamicUser=yes
StateDirectory=cloudflare-ddns
Now create the timer that triggers the service one minute after boot and then every five minutes:
sudo nano /etc/systemd/system/cloudflare-ddns.timer
[Unit]
Description=Run cloudflare-ddns every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
Reload systemd, run the service once by hand and check its log:
sudo systemctl daemon-reload
sudo systemctl start cloudflare-ddns.service
journalctl -u cloudflare-ddns.service -n 5 --no-pager
Sep 25 10:02:11 homelab systemd[1]: Starting cloudflare-ddns.service - Update Cloudflare DNS record with the current public IP...
Sep 25 10:02:12 homelab cloudflare-ddns[4120]: Updated home.example.com to 203.0.113.45
Sep 25 10:02:12 homelab systemd[1]: Finished cloudflare-ddns.service - Update Cloudflare DNS record with the current public IP.
Run it a second time: the log should now say IP unchanged (203.0.113.45), nothing to do. Then enable the timer:
sudo systemctl enable --now cloudflare-ddns.timer
systemctl list-timers cloudflare-ddns.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Thu 2026-09-25 10:07:12 UTC 4min 51s Thu 2026-09-25 10:02:12 UTC 8s ago cloudflare-ddns.timer cloudflare-ddns.service
Step 6 - Verifying the DNS record
Query a public resolver to confirm the record now returns your public IP:
dig +short home.example.com @1.1.1.1
203.0.113.45
Compare it with the address the machine is really using:
curl -4 -s https://api.ipify.org; echo
Both values should match. When your ISP assigns a new address, the record is updated within five minutes and resolvers pick it up once the 60-second TTL expires.
Alternative: updating your own BIND server with nsupdate
If you run an authoritative BIND 9 server instead of using Cloudflare, you can update records with RFC 2136 dynamic updates, authenticated with a TSIG key. The commands below assume Ubuntu 24.04 on the DNS server, which already serves example.com.
On the DNS server, generate a key and restrict its permissions:
sudo tsig-keygen -a hmac-sha256 ddns-home | sudo tee /etc/bind/ddns-home.key > /dev/null
sudo chown root:bind /etc/bind/ddns-home.key
sudo chmod 640 /etc/bind/ddns-home.key
BIND rewrites zones that accept dynamic updates and keeps a .jnl journal next to them, so the zone file must live in a directory named can write to. On Ubuntu that is /var/lib/bind/. Move your zone file there if it is still in /etc/bind/, then edit the zone definition:
sudo nano /etc/bind/named.conf.local
include "/etc/bind/ddns-home.key";
zone "example.com" {
type primary;
file "/var/lib/bind/db.example.com";
update-policy {
grant ddns-home name home.example.com. A;
};
};
The update-policy lets the key change only the A record of home.example.com, nothing else in the zone. Check the configuration and reload BIND:
sudo named-checkconf
sudo systemctl reload named
On the machine with the dynamic IP, install nsupdate, copy the same key file to /etc/ddns-home.key (mode 600, owned by root), and send an update:
sudo apt install -y bind9-dnsutils curl
ip="$(curl -4 -fsS https://api.ipify.org)"
sudo nsupdate -k /etc/ddns-home.key <<EOF
server ns1.example.com
zone example.com
update delete home.example.com. A
update add home.example.com. 60 A ${ip}
send
EOF
Verify against your server:
dig +short home.example.com @ns1.example.com
To automate it, put those commands in a script and reuse the service and timer from Step 5, pointing ExecStart at the new script. Remove DynamicUser=yes and EnvironmentFile= in that case, because the root-only key file must be readable by the process.
Troubleshooting
The service fails with Authentication error or HTTP 403. The token is wrong, expired, or not scoped to this zone. Recreate it with the Edit zone DNS template and the correct zone, update /etc/cloudflare-ddns.env, and start the service again.
The API returns Invalid dns record identifier or Record does not exist. CF_RECORD_ID does not belong to CF_ZONE_ID. Repeat the lookup in Step 2.
The record was changed by hand and the script does not fix it. The script only calls the API when the detected IP differs from the cached one. Delete the cache to force an update:
sudo rm /var/lib/private/cloudflare-ddns/last_ip
sudo systemctl start cloudflare-ddns.service
nsupdate prints update failed: REFUSED. The key name or secret does not match the server, or the update-policy does not allow that name and type. Check the server log with sudo journalctl -u named -n 50 --no-pager.
nsupdate prints SERVFAIL and the log mentions permission denied. The zone file or its journal is in a directory BIND cannot write to. Move it to /var/lib/bind/ and update the file line.
Conclusion
You now have a hostname that follows your machine's public IP automatically: a small script updates the Cloudflare record through a least-privilege API token, and a systemd timer runs it every five minutes, with the result visible in journalctl. The same timer works with nsupdate if you host DNS yourself. As next steps, you could add an AAAA record by adding a second run with curl -6 and an IPv6 check, point a reverse proxy at home.example.com with a TLS certificate, or restrict which services on the machine are reachable from the Internet with UFW.
