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