Published on

Automating Dynamic DNS with Cloudflare and Python

Authors
  • avatar
    Name
    codebuff
    Twitter

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 requests library (pip install requests)

Step 1: Create a Cloudflare API Token

First, we need to create an API token with permission to edit DNS records.

  1. Go to Cloudflare API Tokens
  2. Click "Create Token"
  3. Use the "Edit zone DNS" template
  4. Set permissions: Zone → DNS → Edit
  5. Set zone resources: Include → Specific zone → (your domain)
  6. 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:

  1. Fetch public IP - Tries multiple services (ipify, ifconfig.me, etc.) for redundancy
  2. Query Cloudflare - Gets the current DNS record if it exists
  3. Compare - Only updates if the IP has actually changed
  4. 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.