Managing DNS records in Cloudflare can be a tedious task, especially if you have multiple domains with several records. The scripts below use Cloudflare’s API to update the same public address across several zones.
One API token scoped only to Zone:Read and DNS:Edit for the affected zones is enough; the global API key from the original version is not needed. Cloudflare token permissions
The token can change public DNS. I keep it in a root-only environment file, never in
config.json, source control, or the command line.
sudo install -m 600 /dev/null /etc/cloudflare-ddns.env
sudoedit /etc/cloudflare-ddns.env
CLOUDFLARE_API_TOKEN=replace-me
My config.json contains only record names:
{
"records": [
{"zone": "example.com", "name": "home.example.com", "type": "A", "proxied": false},
{"zone": "example.net", "name": "home.example.net", "type": "A", "proxied": true}
]
}
Bash: One Record Quickly
This version is convenient for one record and demonstrates the API checks explicitly:
#!/usr/bin/env bash
set -euo pipefail
. /etc/cloudflare-ddns.env
zone=example.com
record=home.example.com
type=A
ip=$(curl --fail --silent --show-error --connect-timeout 5 --max-time 15 https://api.ipify.org)
valid_ipv4() {
local octet
local -a octets
[[ $1 =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
IFS=. read -r -a octets <<<"$1"
for octet in "${octets[@]}"; do
(( 10#$octet <= 255 )) || return 1
done
}
valid_ipv4 "$ip" || { echo "Invalid public IPv4: $ip" >&2; exit 1; }
cf_get() {
curl --fail --silent --show-error --connect-timeout 5 --max-time 20 \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H 'Content-Type: application/json' "$@"
}
zone_json=$(cf_get --get 'https://api.cloudflare.com/client/v4/zones' \
--data-urlencode "name=$zone" --data-urlencode 'status=active')
jq -e '.success == true' >/dev/null <<<"$zone_json"
[[ $(jq '.result | length' <<<"$zone_json") -eq 1 ]] || { echo "Expected one zone" >&2; exit 1; }
zone_id=$(jq -r '.result[0].id' <<<"$zone_json")
record_json=$(cf_get --get "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
--data-urlencode "type=$type" --data-urlencode "name=$record")
jq -e '.success == true' >/dev/null <<<"$record_json"
[[ $(jq '.result | length' <<<"$record_json") -eq 1 ]] || { echo "Expected one record" >&2; exit 1; }
record_id=$(jq -r '.result[0].id' <<<"$record_json")
payload=$(jq -n --arg type "$type" --arg name "$record" --arg content "$ip" \
'{type:$type,name:$name,content:$content,ttl:1,proxied:false}')
updated=$(curl --fail --silent --show-error --connect-timeout 5 --max-time 20 -X PATCH \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H 'Content-Type: application/json' --data "$payload" \
"https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$record_id")
jq -e '.success == true' >/dev/null <<<"$updated"
echo "$record -> $ip"
--data-urlencode protects zone and record names in query parameters, and jq -n builds JSON without shell interpolation. Cloudflare documents the zone and DNS record endpoints.
Python: Multiple Records Clearly
For my multi-zone file, this standard-library version is easier to read and has no package dependency:
#!/usr/bin/env python3
import ipaddress
import json
import os
import urllib.parse
import urllib.request
API = "https://api.cloudflare.com/client/v4"
TOKEN = os.environ["CLOUDFLARE_API_TOKEN"]
def request(method, path, query=None, body=None):
url = API + path
if query:
url += "?" + urllib.parse.urlencode(query)
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
})
with urllib.request.urlopen(req, timeout=15) as response:
result = json.load(response)
if not result.get("success"):
raise RuntimeError(result.get("errors", "Cloudflare API error"))
return result["result"]
def exactly_one(items, description):
if len(items) != 1:
raise RuntimeError(f"Expected one {description}, got {len(items)}")
return items[0]
with urllib.request.urlopen("https://api.ipify.org", timeout=10) as response:
public_ip = str(ipaddress.ip_address(response.read().decode().strip()))
with open("config.json", encoding="utf-8") as handle:
config = json.load(handle)
for item in config["records"]:
zone = exactly_one(request("GET", "/zones", {"name": item["zone"], "status": "active"}), "zone")
record = exactly_one(request("GET", f"/zones/{zone['id']}/dns_records", {
"type": item["type"], "name": item["name"]
}), "DNS record")
request("PATCH", f"/zones/{zone['id']}/dns_records/{record['id']}", body={
"type": item["type"], "name": item["name"], "content": public_ip,
"ttl": 1, "proxied": item.get("proxied", False),
})
print(f"{item['name']} -> {public_ip}")
I run it with the secret loaded only for the process:
set -a
. /etc/cloudflare-ddns.env
set +a
python3 cloudflare-ddns.py
Both versions stop on empty or ambiguous API matches and validate every Cloudflare response instead of silently updating the wrong record.
Buy Me a Coffee