- Published on
Automating Dynamic DNS with Cloudflare and Python
- Authors
- Name
- codebuff
Introduction
If you're running a home server or self-hosting services, you've likely encountered the challenge of dynamic IP addresses. Most residential ISPs assign dynamic IPs that can change periodically, breaking access to your services. While many people turn to services like DynDNS or No-IP, you can easily roll your own solution using Cloudflare's free DNS service and a simple Python script.
In this tutorial, we'll build a dynamic DNS updater that checks your public IP and updates your Cloudflare DNS records automatically.
Prerequisites
- A domain managed by Cloudflare (free tier works fine)
- Python 3.10+ installed
- The
requestslibrary (pip install requests)
Step 1: Create a Cloudflare API Token
First, we need to create an API token with permission to edit DNS records.
- Go to Cloudflare API Tokens
- Click "Create Token"
- Use the "Edit zone DNS" template
- Set permissions: Zone → DNS → Edit
- Set zone resources: Include → Specific zone → (your domain)
- Create and copy the token
You'll also need your Zone ID, found in the Cloudflare dashboard under your domain's Overview page in the right sidebar.
Step 2: Create the Configuration File
Create a directory for the script and add a configuration file:
mkdir -p ~/cloudflare-ddns
Create config.json:
{
"api_token": "your-cloudflare-api-token",
"zone_id": "your-zone-id",
"record_name": "home.yourdomain.com",
"ttl": 300,
"proxied": false
}
Important: Protect this file since it contains your API token:
chmod 600 config.json
Step 3: The Python Script
Here's the complete script that handles IP detection, DNS querying, and updates:
#!/usr/bin/env python3
"""
Cloudflare Dynamic DNS Updater
Updates a Cloudflare DNS A record when your public IP changes.
"""
import json
import logging
import sys
from datetime import datetime
from pathlib import Path
import requests
# Configure logging
SCRIPT_DIR = Path(__file__).parent
LOG_FILE = SCRIPT_DIR / "ddns.log"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(sys.stdout),
],
)
logger = logging.getLogger(__name__)
# IP lookup services (tried in order)
IP_SERVICES = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
"https://checkip.amazonaws.com",
]
CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
def load_config() -> dict:
"""Load configuration from config.json."""
config_path = SCRIPT_DIR / "config.json"
if not config_path.exists():
logger.error(f"Config file not found: {config_path}")
sys.exit(1)
with open(config_path) as f:
return json.load(f)
def get_public_ip() -> str | None:
"""Fetch current public IP from external services."""
for service in IP_SERVICES:
try:
response = requests.get(service, timeout=10)
response.raise_for_status()
ip = response.text.strip()
logger.debug(f"Got IP {ip} from {service}")
return ip
except requests.RequestException as e:
logger.warning(f"Failed to get IP from {service}: {e}")
continue
logger.error("Failed to get public IP from all services")
return None
def get_dns_record(api_token: str, zone_id: str, record_name: str) -> dict | None:
"""Get the current DNS record from Cloudflare."""
headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}
url = f"{CLOUDFLARE_API_BASE}/zones/{zone_id}/dns_records"
params = {"type": "A", "name": record_name}
try:
response = requests.get(url, headers=headers, params=params, timeout=30)
response.raise_for_status()
data = response.json()
if not data.get("success"):
logger.error(f"Cloudflare API error: {data.get('errors')}")
return None
records = data.get("result", [])
if not records:
logger.info(f"No A record found for {record_name}")
return None
return records[0]
except requests.RequestException as e:
logger.error(f"Failed to get DNS record: {e}")
return None
def create_dns_record(
api_token: str, zone_id: str, record_name: str, ip: str, ttl: int, proxied: bool
) -> bool:
"""Create a new DNS A record."""
headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}
url = f"{CLOUDFLARE_API_BASE}/zones/{zone_id}/dns_records"
payload = {
"type": "A",
"name": record_name,
"content": ip,
"ttl": ttl,
"proxied": proxied,
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
if data.get("success"):
logger.info(f"Created DNS record {record_name} -> {ip}")
return True
else:
logger.error(f"Failed to create DNS record: {data.get('errors')}")
return False
except requests.RequestException as e:
logger.error(f"Failed to create DNS record: {e}")
return False
def update_dns_record(
api_token: str,
zone_id: str,
record_id: str,
record_name: str,
ip: str,
ttl: int,
proxied: bool,
) -> bool:
"""Update an existing DNS A record."""
headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}
url = f"{CLOUDFLARE_API_BASE}/zones/{zone_id}/dns_records/{record_id}"
payload = {
"type": "A",
"name": record_name,
"content": ip,
"ttl": ttl,
"proxied": proxied,
}
try:
response = requests.put(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
if data.get("success"):
logger.info(f"Updated DNS record {record_name} -> {ip}")
return True
else:
logger.error(f"Failed to update DNS record: {data.get('errors')}")
return False
except requests.RequestException as e:
logger.error(f"Failed to update DNS record: {e}")
return False
def main() -> int:
"""Main entry point."""
logger.info("Starting DDNS update check")
# Load configuration
config = load_config()
api_token = config.get("api_token")
zone_id = config.get("zone_id")
record_name = config.get("record_name")
ttl = config.get("ttl", 300)
proxied = config.get("proxied", False)
if not all([api_token, zone_id, record_name]):
logger.error("Missing required configuration")
return 1
# Get current public IP
current_ip = get_public_ip()
if not current_ip:
return 1
logger.info(f"Current public IP: {current_ip}")
# Get existing DNS record
record = get_dns_record(api_token, zone_id, record_name)
if record is None:
# No record exists, create one
logger.info(f"Creating new DNS record for {record_name}")
success = create_dns_record(
api_token, zone_id, record_name, current_ip, ttl, proxied
)
return 0 if success else 1
record_ip = record.get("content")
record_id = record.get("id")
if record_ip == current_ip:
logger.info(f"IP unchanged ({current_ip}), no update needed")
return 0
# IP has changed, update the record
logger.info(f"IP changed: {record_ip} -> {current_ip}")
success = update_dns_record(
api_token, zone_id, record_id, record_name, current_ip, ttl, proxied
)
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())
Make it executable:
chmod +x ddns.py
Step 4: Schedule with Cron
The script should run periodically to check for IP changes. How often depends on your ISP. For stable connections like Verizon FiOS, every 6 hours is sufficient since IPs rarely change. For less stable connections, you might want every hour or even every 5 minutes.
Add a cron job:
crontab -e
For every 6 hours:
0 */6 * * * /usr/bin/python3 /home/user/cloudflare-ddns/ddns.py >> /home/user/cloudflare-ddns/ddns.log 2>&1
For every hour:
0 * * * * /usr/bin/python3 /home/user/cloudflare-ddns/ddns.py >> /home/user/cloudflare-ddns/ddns.log 2>&1
Step 5: Test It
Run the script manually to verify everything works:
python3 ddns.py
You should see output like:
2026-02-08 14:05:34,948 - INFO - Starting DDNS update check
2026-02-08 14:05:35,026 - INFO - Current public IP: 108.28.45.48
2026-02-08 14:05:35,508 - INFO - No A record found for home.yourdomain.com
2026-02-08 14:05:35,508 - INFO - Creating new DNS record for home.yourdomain.com
2026-02-08 14:05:35,841 - INFO - Created DNS record home.yourdomain.com -> 108.28.45.48
Verify the DNS record:
dig home.yourdomain.com
How It Works
The script follows a simple flow:
- Fetch public IP - Tries multiple services (ipify, ifconfig.me, etc.) for redundancy
- Query Cloudflare - Gets the current DNS record if it exists
- Compare - Only updates if the IP has actually changed
- Create or Update - Creates a new record or updates the existing one
This approach minimizes unnecessary API calls since it only writes to Cloudflare when something changes.
Conclusion
With about 200 lines of Python, you have a fully functional dynamic DNS solution that's:
- Free - Uses Cloudflare's free tier
- Reliable - Multiple IP lookup services for redundancy
- Efficient - Only updates when the IP actually changes
- Self-hosted - No dependency on third-party DDNS services
You can extend this script to handle multiple records, send notifications on IP changes, or integrate with other DNS providers. The Cloudflare API is well-documented and supports many more operations if you need them.