close

DEV Community

Vix
Vix

Posted on

How to build a DDNS updater with no API key

How to build a DDNS updater with no API key

Dynamic DNS (DDNS) is one of those problems that sounds simple — keep a DNS record pointing at your current IP — but tends to involve more moving parts than expected. Most solutions require creating an account, generating an API key, and depending on a third-party service to detect your own IP address. This article shows how to build a complete DDNS updater that detects your public IP without any API key, using IPPubblico.org as the IP detection layer.

The DDNS update step (writing to your DNS provider) still requires your DNS provider's credentials, but the IP detection part — which is what breaks or goes stale most often — is completely key-free.


How DDNS updaters work

The basic loop is always the same:

  1. Get your current public IP address
  2. Compare it to the last known IP (stored locally or retrieved from DNS)
  3. If it changed, update the DNS record
  4. Wait and repeat

The IP detection step (step 1) is where most updaters introduce unnecessary dependencies. Many use ip-api.com, ipify.org, or similar services that require rate limit management, API keys for higher tiers, or have unreliable uptime. IPPubblico returns a plain text response with no authentication overhead — ideal for scripts that run every few minutes.


Shell script (Linux / OpenWRT / router firmware)

The most portable version. Runs on any system with curl and a cron daemon — including OpenWRT routers, NAS devices, and embedded Linux boards.

#!/bin/sh
# ddns_updater.sh
# Detects public IP change and updates a Cloudflare DNS record

# --- Configuration ---
CF_API_TOKEN="your_cloudflare_api_token"
CF_ZONE_ID="your_zone_id"
CF_RECORD_ID="your_record_id"
CF_RECORD_NAME="home.yourdomain.com"
IP_CACHE_FILE="/tmp/ddns_last_ip.txt"
LOG_FILE="/var/log/ddns_updater.log"

# --- Functions ---
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}

get_public_ip() {
    curl -s --max-time 10 https://ipv4.ippubblico.org/
}

update_cloudflare() {
    local NEW_IP="$1"
    curl -s -X PUT \
        "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records/${CF_RECORD_ID}" \
        -H "Authorization: Bearer ${CF_API_TOKEN}" \
        -H "Content-Type: application/json" \
        --data "{\"type\":\"A\",\"name\":\"${CF_RECORD_NAME}\",\"content\":\"${NEW_IP}\",\"ttl\":60}"
}

# --- Main ---
CURRENT_IP=$(get_public_ip)

if [ -z "$CURRENT_IP" ]; then
    log "ERROR: Failed to get public IP"
    exit 1
fi

LAST_IP=""
if [ -f "$IP_CACHE_FILE" ]; then
    LAST_IP=$(cat "$IP_CACHE_FILE")
fi

if [ "$CURRENT_IP" = "$LAST_IP" ]; then
    log "IP unchanged: $CURRENT_IP"
    exit 0
fi

log "IP changed: $LAST_IP -> $CURRENT_IP. Updating DNS..."

RESULT=$(update_cloudflare "$CURRENT_IP")

if echo "$RESULT" | grep -q '"success":true'; then
    echo "$CURRENT_IP" > "$IP_CACHE_FILE"
    log "DNS updated successfully to $CURRENT_IP"
else
    log "ERROR: DNS update failed. Response: $RESULT"
    exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Add to cron (runs every 5 minutes):

*/5 * * * * /usr/local/bin/ddns_updater.sh
Enter fullscreen mode Exit fullscreen mode

Python (cross-platform)

A more robust version with proper error handling, structured logging, and support for multiple DNS providers.

#!/usr/bin/env python3
# ddns_updater.py

import requests
import json
import logging
import time
from pathlib import Path
from dataclasses import dataclass
from typing import Optional

# --- Configuration ---
CLOUDFLARE_API_TOKEN = "your_cloudflare_api_token"
CLOUDFLARE_ZONE_ID   = "your_zone_id"
CLOUDFLARE_RECORD_ID = "your_record_id"
RECORD_NAME          = "home.yourdomain.com"
CACHE_FILE           = Path("/tmp/ddns_last_ip.txt")
CHECK_INTERVAL       = 300  # seconds

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(message)s',
    handlers=[
        logging.FileHandler('/var/log/ddns_updater.log'),
        logging.StreamHandler()
    ]
)
log = logging.getLogger(__name__)


def get_public_ip() -> Optional[str]:
    """Get current public IPv4 address — no API key required."""
    try:
        res = requests.get('https://ipv4.ippubblico.org/', timeout=10)
        res.raise_for_status()
        return res.text.strip()
    except requests.RequestException as e:
        log.error(f"Failed to get public IP: {e}")
        return None


def get_cached_ip() -> Optional[str]:
    try:
        return CACHE_FILE.read_text().strip()
    except FileNotFoundError:
        return None


def save_ip(ip: str) -> None:
    CACHE_FILE.write_text(ip)


def update_cloudflare(new_ip: str) -> bool:
    """Update an A record on Cloudflare."""
    url = f"https://api.cloudflare.com/client/v4/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{CLOUDFLARE_RECORD_ID}"
    headers = {
        "Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}",
        "Content-Type": "application/json",
    }
    payload = {
        "type": "A",
        "name": RECORD_NAME,
        "content": new_ip,
        "ttl": 60,
    }
    try:
        res = requests.put(url, headers=headers, json=payload, timeout=10)
        data = res.json()
        if data.get("success"):
            return True
        log.error(f"Cloudflare API error: {data.get('errors')}")
        return False
    except requests.RequestException as e:
        log.error(f"Cloudflare request failed: {e}")
        return False


def check_and_update() -> None:
    current_ip = get_public_ip()
    if not current_ip:
        return

    last_ip = get_cached_ip()

    if current_ip == last_ip:
        log.info(f"IP unchanged: {current_ip}")
        return

    log.info(f"IP changed: {last_ip or 'unknown'}{current_ip}")

    if update_cloudflare(current_ip):
        save_ip(current_ip)
        log.info(f"DNS updated successfully to {current_ip}")
    else:
        log.error("DNS update failed")


if __name__ == "__main__":
    log.info("DDNS updater started")
    while True:
        check_and_update()
        time.sleep(CHECK_INTERVAL)
Enter fullscreen mode Exit fullscreen mode

Run as a systemd service:

[Unit]
Description=DDNS Updater
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/bin/ddns_updater.py
Restart=on-failure
RestartSec=30

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Node.js

// ddns_updater.js — requires Node 18+
import fs   from 'fs/promises';
import path from 'path';

const CF_API_TOKEN  = 'your_cloudflare_api_token';
const CF_ZONE_ID    = 'your_zone_id';
const CF_RECORD_ID  = 'your_record_id';
const RECORD_NAME   = 'home.yourdomain.com';
const CACHE_FILE    = '/tmp/ddns_last_ip.txt';
const INTERVAL_MS   = 5 * 60 * 1000;

async function getPublicIP() {
  const res = await fetch('https://ipv4.ippubblico.org/', {
    signal: AbortSignal.timeout(10000)
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.text()).trim();
}

async function getCachedIP() {
  try { return (await fs.readFile(CACHE_FILE, 'utf-8')).trim(); }
  catch { return null; }
}

async function updateCloudflare(ip) {
  const res = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records/${CF_RECORD_ID}`,
    {
      method: 'PUT',
      headers: {
        'Authorization': `Bearer ${CF_API_TOKEN}`,
        'Content-Type':  'application/json',
      },
      body: JSON.stringify({ type: 'A', name: RECORD_NAME, content: ip, ttl: 60 }),
      signal: AbortSignal.timeout(10000),
    }
  );
  const data = await res.json();
  return data.success === true;
}

async function check() {
  try {
    const current = await getPublicIP();
    const last    = await getCachedIP();

    if (current === last) {
      console.log(`[${new Date().toISOString()}] IP unchanged: ${current}`);
      return;
    }

    console.log(`[${new Date().toISOString()}] IP changed: ${last ?? 'unknown'}${current}`);

    if (await updateCloudflare(current)) {
      await fs.writeFile(CACHE_FILE, current, 'utf-8');
      console.log(`[${new Date().toISOString()}] DNS updated to ${current}`);
    } else {
      console.error('DNS update failed');
    }
  } catch (err) {
    console.error('Error:', err.message);
  }
}

check();
setInterval(check, INTERVAL_MS);
Enter fullscreen mode Exit fullscreen mode

Go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strings"
    "time"
)

const (
    cfAPIToken  = "your_cloudflare_api_token"
    cfZoneID    = "your_zone_id"
    cfRecordID  = "your_record_id"
    recordName  = "home.yourdomain.com"
    cacheFile   = "/tmp/ddns_last_ip.txt"
    checkInterval = 5 * time.Minute
)

func getPublicIP() (string, error) {
    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := client.Get("https://ipv4.ippubblico.org/")
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    return strings.TrimSpace(string(body)), nil
}

func getCachedIP() string {
    data, err := os.ReadFile(cacheFile)
    if err != nil {
        return ""
    }
    return strings.TrimSpace(string(data))
}

func updateCloudflare(ip string) error {
    url := fmt.Sprintf(
        "https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s",
        cfZoneID, cfRecordID,
    )
    payload := map[string]interface{}{
        "type":    "A",
        "name":    recordName,
        "content": ip,
        "ttl":     60,
    }
    body, _ := json.Marshal(payload)

    req, _ := http.NewRequest("PUT", url, bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+cfAPIToken)
    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    var result map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&result)
    if result["success"] != true {
        return fmt.Errorf("cloudflare error: %v", result["errors"])
    }
    return nil
}

func checkAndUpdate() {
    current, err := getPublicIP()
    if err != nil {
        log.Printf("Failed to get IP: %v", err)
        return
    }

    last := getCachedIP()
    if current == last {
        log.Printf("IP unchanged: %s", current)
        return
    }

    log.Printf("IP changed: %s → %s", last, current)

    if err := updateCloudflare(current); err != nil {
        log.Printf("DNS update failed: %v", err)
        return
    }

    os.WriteFile(cacheFile, []byte(current), 0644)
    log.Printf("DNS updated to %s", current)
}

func main() {
    log.Println("DDNS updater started")
    checkAndUpdate()
    for range time.Tick(checkInterval) {
        checkAndUpdate()
    }
}
Enter fullscreen mode Exit fullscreen mode

How the IP detection works

All examples use https://ipv4.ippubblico.org/ — a dedicated endpoint that forces an IPv4 connection and returns nothing but the bare IP address as plain text. This makes parsing trivial in every language:

203.0.113.42
Enter fullscreen mode Exit fullscreen mode

No JSON to unwrap, no status fields to check, no authentication headers. For a script that runs every 5 minutes, this simplicity matters — fewer things to break.

If you want to verify DNS propagation after an update (checking that the IP in the DNS matches your current IP), a quick way to confirm your current IP without a DNS lookup:

CURRENT=$(curl -s https://ipv4.ippubblico.org/)
DNS_IP=$(dig +short home.yourdomain.com @8.8.8.8)
echo "Current IP : $CURRENT"
echo "DNS record : $DNS_IP"
[ "$CURRENT" = "$DNS_IP" ] && echo "In sync" || echo "Update pending"
Enter fullscreen mode Exit fullscreen mode

What about IPv6?

If your setup supports IPv6 and your DNS provider supports AAAA records, you can detect the IPv6 address the same way:

IPV6=$(curl -s https://ipv6.ippubblico.org/)
# Returns the IPv6 address or nothing if not available
Enter fullscreen mode Exit fullscreen mode

If the endpoint returns an empty response or fails to connect, the client has no IPv6 connectivity — handle it gracefully and fall back to IPv4 only.


Summary

Language Dependency Runs as
Shell curl cron / init script
Python requests systemd service / cron
Node.js none (native fetch) systemd service / PM2
Go none (stdlib) systemd service / binary

IP detection across all examples: https://ipv4.ippubblico.org/ — no API key, no account, plain text response.

Full documentation: ippubblico.org/docs.html


Which DNS provider are you using for DDNS? Share your setup in the comments.

Top comments (0)