DDNS with Cloudflare
Prerequisites
This guide assumes you have the following prerequisites in place:
- FluxCD
- Cert-Manager
- A domain name registered with cloudflare.
- A docker registry to store the ddns-updater image.
DDNS
In this guide, we will set up Dynamic DNS (DDNS) using Cloudflare as the DNS provider. This will allow us to automatically update our DNS records when our IP address changes, ensuring that our services remain accessible.
Building the DDNS Updater Image
Create these two python files.
ddns_updater.pyimport os import sys import time import logging import ipaddress import cloudflare from cloudflare import Cloudflare from kubernetes import client, config logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", stream=sys.stdout, ) logger = logging.getLogger("ddns-updater") CF_API_TOKEN: str = os.environ["CF_API_TOKEN"] CF_ZONE_ID: str = os.environ["CF_ZONE_ID"] DNS_DOMAIN: str = os.environ["DNS_DOMAIN"] DNS_TTL: int = int(os.getenv("DNS_TTL", "120")) DNS_PROXIED: bool = os.getenv("DNS_PROXIED", "false").lower() == "true" POLL_INTERVAL: int = int(os.getenv("POLL_INTERVAL", "300")) NAMESPACE: str = os.getenv("NAMESPACE", "ddns-updater") CONFIGMAP_NAME: str = os.getenv("CONFIGMAP_NAME", "node-ipv6-addresses") NODE_NAMES: list[str] = [ n.strip() for n in os.environ["NODE_NAMES"].split(",") if n.strip() ] def load_k8s_config() -> None: try: config.load_incluster_config() logger.info("Loaded in-cluster Kubernetes config") except config.ConfigException: config.load_kube_config() logger.info("Loaded local kubeconfig") def read_node_ips(v1: client.CoreV1Api) -> dict[str, str]: try: cm = v1.read_namespaced_config_map(name=CONFIGMAP_NAME, namespace=NAMESPACE) data = cm.data or {} except client.exceptions.ApiException as exc: if exc.status == 404: logger.warning("ConfigMap %s not found yet -- agents may not have started", CONFIGMAP_NAME) return {} raise valid: dict[str, str] = {} for node, raw_ip in data.items(): try: ip = ipaddress.ip_address(raw_ip.strip()) if isinstance(ip, ipaddress.IPv6Address) and ip.is_global: valid[node] = str(ip) else: logger.warning("Ignoring non-global IPv6 for node %s: %s", node, raw_ip) except ValueError: logger.warning("Invalid IP in ConfigMap for node %s: %r", node, raw_ip) return valid def get_wildcard_records(cf: Cloudflare) -> dict[str, str]: wildcard = f"*.{DNS_DOMAIN}" records: dict[str, str] = {} page = 1 while True: result = cf.dns.records.list( zone_id=CF_ZONE_ID, type="AAAA", name=wildcard, per_page=100, page=page, ) for rec in result.result: records[rec.id] = rec.content if page >= result.result_info.total_pages: break page += 1 return records def sync_wildcard_records(cf: Cloudflare, desired_ips: list[str]) -> None: wildcard = f"*.{DNS_DOMAIN}" existing = get_wildcard_records(cf) existing_by_ip = {ip: rec_id for rec_id, ip in existing.items()} desired_set = set(desired_ips) existing_set = set(existing_by_ip.keys()) to_delete = existing_set - desired_set to_create = desired_set - existing_set for ip in to_delete: rec_id = existing_by_ip[ip] logger.info("Deleting %s -> %s", wildcard, ip) cf.dns.records.delete(dns_record_id=rec_id, zone_id=CF_ZONE_ID) for ip in to_create: logger.info("Creating %s -> %s", wildcard, ip) cf.dns.records.create( zone_id=CF_ZONE_ID, type="AAAA", name=wildcard, content=ip, ttl=DNS_TTL, proxied=DNS_PROXIED, ) if not to_delete and not to_create: logger.info("No changes needed for %s (%d records)", wildcard, len(desired_set)) else: logger.info( "Sync complete for %s: created=%d deleted=%d total=%d", wildcard, len(to_create), len(to_delete), len(desired_set), ) def run_once(cf: Cloudflare, v1: client.CoreV1Api) -> None: node_ips = read_node_ips(v1) if not node_ips: logger.warning("No node IPs available yet, skipping Cloudflare update") return missing = [n for n in NODE_NAMES if n not in node_ips] if missing: logger.warning("No IP reported yet for nodes: %s", missing) desired_ips = [node_ips[n] for n in NODE_NAMES if n in node_ips] logger.info("Round-robin IPs for *.%s: %s", DNS_DOMAIN, desired_ips) try: sync_wildcard_records(cf, desired_ips) except cloudflare.APIError as exc: logger.error("Cloudflare API error: %s", exc) def main() -> None: load_k8s_config() v1 = client.CoreV1Api() cf = Cloudflare(api_token=CF_API_TOKEN) logger.info( "DDNS updater started | zone=%s domain=%s nodes=%s poll=%ds", CF_ZONE_ID, DNS_DOMAIN, NODE_NAMES, POLL_INTERVAL, ) while True: try: run_once(cf, v1) except Exception as exc: logger.exception("Unhandled error: %s", exc) logger.info("Sleeping %ds...", POLL_INTERVAL) time.sleep(POLL_INTERVAL) if __name__ == "__main__": main()node_agent.pyimport os import sys import time import logging import subprocess import ipaddress from kubernetes import client, config logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", stream=sys.stdout, ) logger = logging.getLogger("node-agent") NODE_NAME: str = os.environ["NODE_NAME"] NAMESPACE: str = os.getenv("NAMESPACE", "ddns-updater") CONFIGMAP_NAME: str = os.getenv("CONFIGMAP_NAME", "node-ipv6-addresses") POLL_INTERVAL: int = int(os.getenv("POLL_INTERVAL", "300")) IPV6_ECHO_URLS: list[str] = [ "https://api6.ipify.org", "https://ipv6.icanhazip.com", "https://v6.ident.me", ] CURL_TIMEOUT: int = int(os.getenv("CURL_TIMEOUT", "10")) def fetch_ipv6() -> str | None: for url in IPV6_ECHO_URLS: try: result = subprocess.run( [ "curl", "--silent", "--fail", "--max-time", str(CURL_TIMEOUT), url, ], capture_output=True, text=True, timeout=CURL_TIMEOUT + 2, ) if result.returncode != 0: logger.warning("curl failed for %s: %s", url, result.stderr.strip()) continue raw = result.stdout.strip() ip = ipaddress.ip_address(raw) if isinstance(ip, ipaddress.IPv6Address) and ip.is_global: logger.info("Discovered IPv6 via %s: %s", url, raw) return raw logger.warning("Address from %s is not a global IPv6: %s", url, raw) except subprocess.TimeoutExpired: logger.warning("curl timed out for %s", url) except ValueError: logger.warning("Invalid IP returned from %s: %r", url, result.stdout.strip()) except Exception as exc: logger.warning("Unexpected error querying %s: %s", url, exc) return None def patch_configmap(v1: client.CoreV1Api, ipv6: str) -> None: patch_body = { "apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": CONFIGMAP_NAME, "namespace": NAMESPACE}, "data": {NODE_NAME: ipv6}, } try: v1.patch_namespaced_config_map( name=CONFIGMAP_NAME, namespace=NAMESPACE, body=patch_body, ) logger.info("Patched ConfigMap %s[%s] = %s", CONFIGMAP_NAME, NODE_NAME, ipv6) except client.exceptions.ApiException as exc: if exc.status == 404: v1.create_namespaced_config_map( namespace=NAMESPACE, body=client.V1ConfigMap( metadata=client.V1ObjectMeta( name=CONFIGMAP_NAME, namespace=NAMESPACE, ), data={NODE_NAME: ipv6}, ), ) logger.info("Created ConfigMap %s with %s = %s", CONFIGMAP_NAME, NODE_NAME, ipv6) else: raise def main() -> None: try: config.load_incluster_config() except config.ConfigException: config.load_kube_config() v1 = client.CoreV1Api() logger.info("Node agent started on node=%s, polling every %ds", NODE_NAME, POLL_INTERVAL) while True: ipv6 = fetch_ipv6() if ipv6: try: patch_configmap(v1, ipv6) except Exception as exc: logger.error("Failed to update ConfigMap: %s", exc) else: logger.error("Could not determine IPv6 address for node %s", NODE_NAME) time.sleep(POLL_INTERVAL) if __name__ == "__main__": main()
Create a
requirements.txtcloudflare>=3.1.0 kubernetes>=29.0.0Create a
DockerfileFROM python:3.12-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt FROM python:3.12-slim LABEL org.opencontainers.image.title="cloudflare-ddns-ipv6" LABEL org.opencontainers.image.description="Dynamic DNS updater for IPv6 AAAA records on k3s" RUN apt-get update && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* RUN useradd --no-create-home --shell /bin/false ddns WORKDIR /app COPY --from=builder /install /usr/local COPY ddns_updater.py node_agent.py ./ RUN chmod +x ddns_updater.py node_agent.py && chown -R ddns:ddns /app USER ddns CMD ["python", "-u", "ddns_updater.py"]Build and push the image to your registry:
docker build -t <your-registry>/cloudflare-ddns-ipv6:latest . docker push <your-registry>/cloudflare-ddns-ipv6:latest
Installation
Create the following directory structure for DDNS:
ddns/ ├── cf-secret.yml ├── ddns-cm.yml ├── ddns-node-agent.yml ├── ddns-rbac.yml ├── ddns-service-account.yml ├── ddns-updater.yml └── namespace.ymlCreate a
cf-secret-tmp.ymlwith the following content and replace the placeholders with your Cloudflare API token and zone ID:apiVersion: v1 kind: Secret metadata: name: cloudflare-api namespace: ddns-updater type: Opaque data: CF_API_TOKEN: <base64-encoded-api-token> CF_ZONE_ID: <base64-encoded-zone-id>Encrypt the
cf-secret-tmp.ymlfile using Sealed-Secrets and save it ascf-secret.yml:kubeseal --format=yaml < cf-secret-tmp.yml > cf-secret.yml && \ rm cf-secret-tmp.ymlCreate the
ddns-cm.ymlConfigMap:--- apiVersion: v1 kind: ConfigMap metadata: name: ddns-config namespace: ddns-updater data: DNS_DOMAIN: "example.com" NODE_NAMES: "kube-01,kube-02,kube-03,kube-04,kube-05" DNS_TTL: "120" DNS_PROXIED: "false" POLL_INTERVAL: "300" NAMESPACE: "ddns-updater" CONFIGMAP_NAME: "node-ipv6-addresses" CURL_TIMEOUT: "10"Create the
ddns-node-agent.ymlDaemonSet:--- apiVersion: apps/v1 kind: DaemonSet metadata: name: ddns-node-agent namespace: ddns-updater labels: app: ddns-node-agent spec: selector: matchLabels: app: ddns-node-agent template: metadata: labels: app: ddns-node-agent spec: serviceAccountName: ddns-updater hostNetwork: true dnsPolicy: ClusterFirstWithHostNet securityContext: runAsNonRoot: true runAsUser: 1000 seccompProfile: type: RuntimeDefault containers: - name: node-agent image: <your-registry>/cloudflare-ddns-ipv6:latest imagePullPolicy: Always command: - "/bin/bash" - "-c" - "exec python -u node_agent.py" env: - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName envFrom: - configMapRef: name: ddns-config resources: requests: cpu: "20m" memory: "32Mi" limits: cpu: "100m" memory: "64Mi" securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"]Create the
ddns-rbac.ymlRole and RoleBinding:--- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: ddns-configmap-writer namespace: ddns-updater rules: - apiGroups: [""] resources: ["configmaps"] resourceNames: ["node-ipv6-addresses"] verbs: ["get", "patch", "update", "create"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: ddns-configmap-reader namespace: ddns-updater rules: - apiGroups: [""] resources: ["configmaps"] resourceNames: ["node-ipv6-addresses"] verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: ddns-agent-configmap-writer namespace: ddns-updater roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: ddns-configmap-writer subjects: - kind: ServiceAccount name: ddns-updater namespace: ddns-updater --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: ddns-updater-configmap-reader namespace: ddns-updater roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: ddns-configmap-reader subjects: - kind: ServiceAccount name: ddns-updater namespace: ddns-updaterCreate the
ddns-service-account.ymlServiceAccount:--- apiVersion: v1 kind: ServiceAccount metadata: name: ddns-updater namespace: ddns-updaterCreate the
ddns-updater.ymlDeployment:--- apiVersion: apps/v1 kind: Deployment metadata: name: ddns-updater namespace: ddns-updater labels: app: ddns-updater spec: replicas: 1 selector: matchLabels: app: ddns-updater template: metadata: labels: app: ddns-updater spec: serviceAccountName: ddns-updater securityContext: runAsNonRoot: true runAsUser: 1000 seccompProfile: type: RuntimeDefault containers: - name: ddns-updater image: <your-registry>/cloudflare-ddns-ipv6:latest imagePullPolicy: Always envFrom: - configMapRef: name: ddns-config - secretRef: name: cloudflare-creds resources: requests: cpu: "50m" memory: "64Mi" limits: cpu: "200m" memory: "128Mi" securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"]Create the
namespace.ymlNamespace:--- apiVersion: v1 kind: Namespace metadata: name: ddns-updaterCommit and push all the files to your Git repo and wait for flux to apply all the manifests.