timothymiller/cloudflare-ddns

🦀 Rust based dynamic DNS (DDNS) updater for Cloudflare

Rust

4,534

216 commits

updated Oct 11, 2026

See the code

README

Cloudflare DDNS

🌍 Cloudflare DDNS

Access your home network remotely via a custom domain name without a static IP!

A feature-complete dynamic DNS client for Cloudflare, written in Rust. The smallest and most memory-efficient open-source Cloudflare DDNS Docker image available — ~1.1 MB image size and ~3.5 MB RAM at runtime, smaller and leaner than Go-based alternatives. Built as a fully static binary from scratch with zero runtime dependencies.

Configure everything with environment variables. Supports notifications, heartbeat monitoring, WAF list management, flexible scheduling, and more.

Docker Pulls Docker Image Size

✨ Features

  • 🔍 Multiple IP detection providers — Cloudflare Trace, Cloudflare DNS-over-HTTPS, ipify, local interface, custom URL, or static IPs
  • 📡 IPv4 and IPv6 — Full dual-stack support with independent provider configuration
  • 🌐 Multiple domains and zones — Update any number of domains across multiple Cloudflare zones
  • 🃏 Wildcard domains — Support for *.example.com records
  • 🌍 Internationalized domain names — Full IDN/punycode support (e.g. münchen.de)
  • 🛡️ WAF list management — Automatically update Cloudflare WAF IP lists
  • 🔔 Notifications — Shoutrrr-compatible notifications (Discord, Slack, Telegram, Gotify, Pushover, Zulip, generic webhooks)
  • 💓 Heartbeat monitoring — Healthchecks.io and Uptime Kuma integration
  • ⏱️ Cron scheduling — Flexible update intervals via cron expressions
  • 🧪 Dry-run mode — Preview changes without modifying DNS records
  • 🧹 Graceful shutdown — Signal handling (SIGINT/SIGTERM) with optional DNS record cleanup
  • 💬 Record comments — Tag managed records with comments for identification
  • 🎯 Managed record regex — Control which records the tool manages via regex matching
  • 🎨 Pretty output with emoji — Configurable emoji and verbosity levels
  • 🔒 Zero-log IP detection — Uses Cloudflare's cdn-cgi/trace by default
  • 🏠 CGNAT-aware local detection — Filters out shared address space (100.64.0.0/10) and private ranges
  • 🚫 Cloudflare IP rejection — Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates
  • 🛟 Outage-proof updates — Transient IP detection failures never delete or overwrite existing DNS records
  • 🤏 Tiny static binary — ~1.1 MB Docker image built from scratch, zero runtime dependencies

🚀 Quick Start

docker run -d \
  --name cloudflare-ddns \
  --restart unless-stopped \
  --network host \
  -e CLOUDFLARE_API_TOKEN=your-api-token \
  -e DOMAINS=example.com,www.example.com \
  timothyjmiller/cloudflare-ddns:latest

That's it. The container detects your public IP and updates the DNS records for your domains every 5 minutes.

⚠️ --network host is required to detect IPv6 addresses. If you only need IPv4, you can omit it and set IP6_PROVIDER=none.

🔑 Authentication

VariableDescription
CLOUDFLARE_API_TOKENAPI token with "Edit DNS" capability
CLOUDFLARE_API_TOKEN_FILEPath to a file containing the API token (Docker secrets compatible)

To generate an API token, go to your Cloudflare Profile and create a token capable of Edit DNS.

🌐 Domains

VariableDescription
DOMAINSComma-separated list of domains to update for both IPv4 and IPv6
IP4_DOMAINSComma-separated list of IPv4-only domains
IP6_DOMAINSComma-separated list of IPv6-only domains

Wildcard domains are supported: *.example.com

At least one of DOMAINS, IP4_DOMAINS, IP6_DOMAINS, or WAF_LISTS must be set.

🔍 IP Detection Providers

VariableDefaultDescription
IP4_PROVIDERipifyIPv4 detection method
IP6_PROVIDERcloudflare.traceIPv6 detection method

Available providers:

ProviderDescription
cloudflare.trace🔒 Cloudflare's /cdn-cgi/trace endpoint (default, zero-log)
cloudflare.doh🌐 Cloudflare DNS-over-HTTPS (whoami.cloudflare TXT query)
ipify🌎 ipify.org API
local🏠 Local IP via system routing table (no network traffic, CGNAT-aware)
local.iface:<name>🔌 IP from a specific network interface (e.g., local.iface:eth0)
local.iface.stable:<name>🔌 Preferred stable IPv6 address from a Linux network interface, excluding temporary/deprecated addresses
url:<url>🔗 Custom HTTP(S) endpoint that returns an IP address
literal:<ips>📌 Static IP addresses (comma-separated)
none🚫 Disable this IP type

🚫 Cloudflare IP Rejection

VariableDefaultDescription
REJECT_CLOUDFLARE_IPStrueReject detected IPs that fall within Cloudflare's IP ranges

Some IP detection providers occasionally return a Cloudflare anycast IP instead of your real public IP. When this happens, your DNS record gets updated to point at Cloudflare infrastructure rather than your actual address.

By default, each update cycle fetches Cloudflare's published IP ranges and skips any detected IP that falls within them. A warning is logged for every rejected IP. If the ranges cannot be fetched, the update is skipped entirely to prevent writing a Cloudflare IP.

To disable this protection, set REJECT_CLOUDFLARE_IPS=false.

⏱️ Scheduling

VariableDefaultDescription
UPDATE_CRON@every 5mUpdate schedule
UPDATE_ON_STARTtrueRun an update immediately on startup
DELETE_ON_STOPfalseDelete managed DNS records on shutdown
DELETE_ON_FAILUREfalseDelete managed DNS records when a provider definitively reports no address of that family (see below)

Schedule formats:

  • @every 5m — Every 5 minutes
  • @every 1h — Every hour
  • @every 30s — Every 30 seconds
  • @once — Run once and exit

When UPDATE_CRON=@once, UPDATE_ON_START must be true and DELETE_ON_STOP must be false.

🛟 Detection Failure Behavior

A failed IP detection never breaks your DNS. Two cases are distinguished:

  • Transient failure — a network-based provider (cloudflare.trace, cloudflare.doh, ipify, url:) errored, or all detected IPs were rejected as Cloudflare IPs. The real IP is unknown, so the update is skipped and existing DNS records and WAF list items are always preserved, regardless of DELETE_ON_FAILURE. If detection fails for one address family, WAF list updates are skipped entirely so the failed family's IPs aren't stripped from the list.
  • Definitive absence — a deterministic provider (none, literal:, local, local.iface:) reports that the host has no address of that family. With DELETE_ON_FAILURE=true the managed records for that family are deleted; with the default false the update is skipped and existing records are preserved.

📝 DNS Record Settings

VariableDefaultDescription
TTL1 (auto)DNS record TTL in seconds (1=auto, or 30-86400)
PROXIEDfalseExpression controlling which domains are proxied through Cloudflare
RECORD_COMMENT(empty)Comment attached to managed DNS records
MANAGED_RECORDS_COMMENT_REGEX(empty)Regex to identify which records are managed (empty = all)

The PROXIED variable supports boolean expressions:

ExpressionMeaning
true☁️ Proxy all domains
false🔓 Don't proxy any domains
is(example.com)🎯 Only proxy example.com
sub(cdn.example.com)🌳 Proxy cdn.example.com and its subdomains
is(a.com) || is(b.com)🔀 Proxy a.com or b.com
!is(vpn.example.com)🚫 Proxy everything except vpn.example.com

Operators: is(), sub(), !, &&, ||, ()

🛡️ WAF Lists

VariableDefaultDescription
WAF_LISTS(empty)Comma-separated WAF lists in account-id/list-name format
WAF_LIST_DESCRIPTION(empty)Description for managed WAF lists
WAF_LIST_ITEM_COMMENT(empty)Comment for WAF list items
MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX(empty)Regex to identify managed WAF list items

WAF list names must match the pattern [a-z0-9_]+.

🔔 Notifications (Shoutrrr)

VariableDescription
SHOUTRRRNewline-separated list of notification service URLs

Supported services:

ServiceURL format
💬 Discorddiscord://token@webhook-id
📨 Slackslack://token-a/token-b/token-c
✈️ Telegramtelegram://bot-token@telegram?chats=chat-id
📡 Gotifygotify://host/path?token=app-token
📲 Pushoverpushover://user-key@api-token
💬 Zulipzulip://bot-mail:bot-key@host/?stream=stream-name&topic=topic-name
🌐 Generic webhookgeneric://host/path or generic+https://host/path

For Zulip, the @ in the bot email may be written literally or percent-encoded (%40), and topic is optional (defaults to Cloudflare DDNS).

Generic webhooks send a JSON payload of {"message": "..."}. Use ?messagekey=<field> to rename the field, e.g. generic://host/path?messagekey=text for services expecting Slack-style payloads.

Notifications are sent when DNS records are updated, created, deleted, or when errors occur.

💓 Heartbeat Monitoring

VariableDescription
HEALTHCHECKSHealthchecks.io ping URL
UPTIMEKUMAUptime Kuma push URL

Heartbeats are sent after each update cycle. On failure, a fail signal is sent. On shutdown, an exit signal is sent.

⏳ Timeouts

VariableDefaultDescription
DETECTION_TIMEOUT5sTimeout for IP detection requests
UPDATE_TIMEOUT30sTimeout for Cloudflare API requests

🖥️ Output

VariableDefaultDescription
EMOJItrueUse emoji in output messages
QUIETfalseSuppress informational output

🏁 CLI Flags

FlagDescription
--dry-run🧪 Preview changes without modifying DNS records
--repeat🔁 Run continuously (legacy config mode only; env var mode uses UPDATE_CRON)

📋 All Environment Variables

VariableDefaultDescription
CLOUDFLARE_API_TOKEN—🔑 API token
CLOUDFLARE_API_TOKEN_FILE—📄 Path to API token file
DOMAINS—🌐 Domains for both IPv4 and IPv6
IP4_DOMAINS—4️⃣ IPv4-only domains
IP6_DOMAINS—6️⃣ IPv6-only domains
IP4_PROVIDERipify🔍 IPv4 detection provider
IP6_PROVIDERcloudflare.trace🔍 IPv6 detection provider
UPDATE_CRON@every 5m⏱️ Update schedule
UPDATE_ON_STARTtrue🚀 Update on startup
DELETE_ON_STOPfalse🧹 Delete records on shutdown
DELETE_ON_FAILUREfalse🧹 Delete records when provider definitively reports no IP
TTL1⏳ DNS record TTL
PROXIEDfalse☁️ Proxied expression
RECORD_COMMENT—💬 DNS record comment
MANAGED_RECORDS_COMMENT_REGEX—🎯 Managed records regex
WAF_LISTS—🛡️ WAF lists to manage
WAF_LIST_DESCRIPTION—📝 WAF list description
WAF_LIST_ITEM_COMMENT—💬 WAF list item comment
MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX—🎯 Managed WAF items regex
DETECTION_TIMEOUT5s⏳ IP detection timeout
UPDATE_TIMEOUT30s⏳ API request timeout
REJECT_CLOUDFLARE_IPStrue🚫 Reject Cloudflare anycast IPs
EMOJItrue🎨 Enable emoji output
QUIETfalse🤫 Suppress info output
HEALTHCHECKS—💓 Healthchecks.io URL
UPTIMEKUMA—💓 Uptime Kuma URL
SHOUTRRR—🔔 Notification URLs (newline-separated)

🚢 Deployment

🐳 Docker Compose

version: '3.9'
services:
  cloudflare-ddns:
    image: timothyjmiller/cloudflare-ddns:latest
    container_name: cloudflare-ddns
    security_opt:
      - no-new-privileges:true
    network_mode: 'host'
    environment:
      - CLOUDFLARE_API_TOKEN=your-api-token
      - DOMAINS=example.com,www.example.com
      - PROXIED=true
      - IP6_PROVIDER=none
      - HEALTHCHECKS=https://hc-ping.com/your-uuid
    restart: unless-stopped

⚠️ Docker requires network_mode: host to access the IPv6 public address.

☸️ Kubernetes

The chart is published to GitHub Container Registry as an OCI artifact.

1. Quick install (single domain):

helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
  --namespace ddns --create-namespace \
  --set cloudflare.apiToken=your-api-token \
  --set domains=example.com

For multiple domains, use a values.yaml file — Helm's --set treats commas as value-list separators.

2. Or use a values.yaml for a full configuration:

cloudflare:
  apiToken: your-api-token   # or use existingSecret

domains: example.com,www.example.com
ip4Provider: cloudflare.trace
ip6Provider: cloudflare.trace   # set to none if IPv6 is not needed

proxied: "true"
updateCron: "@every 5m"

healthchecks: https://hc-ping.com/your-uuid   # optional
helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
  --namespace ddns --create-namespace \
  -f values.yaml

Upgrade:

helm upgrade cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
  --namespace ddns -f values.yaml

Uninstall:

helm uninstall cloudflare-ddns --namespace ddns

⚠️ hostNetwork: true is set by default so the pod can detect IPv6 addresses. Disable it with --set hostNetwork=false if you only need IPv4.

Key values:

ValueDefaultDescription
cloudflare.apiToken""API token (required unless existingSecret is set)
cloudflare.existingSecret""Use a pre-existing Secret instead
domains""Comma-separated domains for A+AAAA records
ip4Domains / ip6Domains""IPv4-only or IPv6-only domains
ip4Provider / ip6Providercloudflare.traceIP detection provider
proxied"false"Proxy through Cloudflare (boolean expression)
updateCron@every 5mUpdate schedule
hostNetworktrueRequired for local IPv6 detection
extraEnv[]Additional env vars for advanced settings

See charts/cloudflare-ddns/values.yaml for all options.

Raw manifest (legacy)

The k8s/cloudflare-ddns.yml manifest uses the legacy JSON config mode. Create a secret containing your config.json and apply:

kubectl create secret generic config-cloudflare-ddns --from-file=config.json -n ddns
kubectl apply -f k8s/cloudflare-ddns.yml

🐧 Linux + Systemd

  1. Build and install:
cargo build --release
sudo cp target/release/cloudflare-ddns /usr/local/bin/
  1. Copy the systemd units from the systemd/ directory:
sudo cp systemd/cloudflare-ddns.service /etc/systemd/system/
sudo cp systemd/cloudflare-ddns.timer /etc/systemd/system/
  1. Place a config.json at /etc/cloudflare-ddns/config.json (the systemd service uses legacy config mode).

  2. Enable the timer:

sudo systemctl enable --now cloudflare-ddns.timer

The timer runs the service every 15 minutes (configurable in cloudflare-ddns.timer).

🔨 Building from Source

cargo build --release

The binary is at target/release/cloudflare-ddns.

🐳 Docker builds

# Single architecture (linux/amd64)
./scripts/docker-build.sh

# Multi-architecture (linux/amd64, linux/arm64, linux/ppc64le)
./scripts/docker-build-all.sh

💻 Supported Platforms

  • 🐳 Docker (amd64, arm64, ppc64le)
  • 🐙 Docker Compose
  • ☸️ Kubernetes + Helm (OCI chart at ghcr.io/timothymiller/cloudflare-ddns)
  • 🐧 Systemd
  • 🍎 macOS, 🪟 Windows, 🐧 Linux — anywhere Rust compiles

📁 Legacy JSON Config File

For backwards compatibility, cloudflare-ddns still supports configuration via a config.json file. This mode is used automatically when no CLOUDFLARE_API_TOKEN environment variable is set.

🚀 Quick Start

cp config-example.json config.json
# Edit config.json with your values
cloudflare-ddns

🔑 Authentication

Use either an API token (recommended) or a legacy API key:

"authentication": {
  "api_token": "Your cloudflare API token with Edit DNS capability"
}

Or with a legacy API key:

"authentication": {
  "api_key": {
    "api_key": "Your cloudflare API Key",
    "account_email": "The email address you use to sign in to cloudflare"
  }
}

📡 IPv4 and IPv6

Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable the interface that is not accessible:

"a": true,
"aaaa": true

⚙️ Config Options

By default, the legacy config file is loaded from ./config.json. Set the CONFIG_PATH environment variable to change the directory:

CONFIG_PATH=/etc/cloudflare-ddns cloudflare-ddns

Or in Docker Compose:

environment:
  - CONFIG_PATH=/config
volumes:
  - /your/path/config.json:/config/config.json
KeyTypeDefaultDescription
cloudflarearrayrequiredList of zone configurations
abooltrueEnable IPv4 (A record) updates
aaaabooltrueEnable IPv6 (AAAA record) updates
purgeUnknownRecordsboolfalseDelete stale/duplicate DNS records
ttlint300DNS record TTL in seconds (30-86400, values < 30 become auto)
ip4_providerstring"cloudflare.trace"IPv4 detection provider (same values as IP4_PROVIDER env var)
ip6_providerstring"cloudflare.trace"IPv6 detection provider (same values as IP6_PROVIDER env var)

🚫 Cloudflare IP Rejection (Legacy Mode)

Cloudflare IP rejection is enabled by default in legacy mode too. To disable it, set REJECT_CLOUDFLARE_IPS=false alongside your config.json:

REJECT_CLOUDFLARE_IPS=false cloudflare-ddns

Or in Docker Compose:

environment:
  - REJECT_CLOUDFLARE_IPS=false
volumes:
  - ./config.json:/config.json

🔍 IP Detection (Legacy Mode)

Legacy mode now uses the same shared provider abstraction as environment variable mode. By default it uses the cloudflare.trace provider, which builds an IP-family-bound HTTP client (0.0.0.0 for IPv4, [::] for IPv6) to guarantee the correct address family on dual-stack hosts.

You can override the detection method per address family with ip4_provider and ip6_provider in your config.json. Supported values are the same as the IP4_PROVIDER / IP6_PROVIDER environment variables: cloudflare.trace, cloudflare.doh, ipify, local, local.iface:<name>, local.iface.stable:<name>, url:<https://...>, none.

Set a provider to "none" to disable detection for that address family (overrides a/aaaa):

{
  "a": true,
  "aaaa": true,
  "ip4_provider": "cloudflare.trace",
  "ip6_provider": "none"
}

Each zone entry contains:

KeyTypeDescription
authenticationobjectAPI token or API key credentials
zone_idstringCloudflare zone ID (found in zone dashboard)
subdomainsarraySubdomain entries to update
proxiedboolDefault proxied status for subdomains in this zone

Subdomain entries can be a simple string or a detailed object:

"subdomains": [
  "",
  "@",
  "www",
  { "name": "vpn", "proxied": true }
]

Use "" or "@" for the root domain. Do not include the base domain name.

🔄 Environment Variable Substitution

In the legacy config file, values can reference environment variables with the CF_DDNS_ prefix:

{
  "cloudflare": [{
    "authentication": {
      "api_token": "${CF_DDNS_API_TOKEN}"
    },
    ...
  }]
}

📠 Example: Multiple Subdomains

{
  "cloudflare": [
    {
      "authentication": {
        "api_token": "your-api-token"
      },
      "zone_id": "your_zone_id",
      "subdomains": [
        { "name": "", "proxied": true },
        { "name": "www", "proxied": true },
        { "name": "vpn", "proxied": false }
      ]
    }
  ],
  "a": true,
  "aaaa": true,
  "purgeUnknownRecords": false,
  "ttl": 300
}

🌐 Example: Multiple Zones

{
  "cloudflare": [
    {
      "authentication": { "api_token": "your-api-token" },
      "zone_id": "first_zone_id",
      "subdomains": [
        { "name": "", "proxied": false }
      ]
    },
    {
      "authentication": { "api_token": "your-api-token" },
      "zone_id": "second_zone_id",
      "subdomains": [
        { "name": "", "proxied": false }
      ]
    }
  ],
  "a": true,
  "aaaa": true,
  "purgeUnknownRecords": false
}

🐳 Docker Compose (legacy config file)

version: '3.9'
services:
  cloudflare-ddns:
    image: timothyjmiller/cloudflare-ddns:latest
    container_name: cloudflare-ddns
    security_opt:
      - no-new-privileges:true
    network_mode: 'host'
    volumes:
      - /YOUR/PATH/HERE/config.json:/config.json
    restart: unless-stopped

🏁 Legacy CLI Flags

In legacy config mode, use --repeat to run continuously (the TTL value is used as the update interval):

cloudflare-ddns --repeat
cloudflare-ddns --repeat --dry-run

📜 License

This project is licensed under the GNU General Public License, version 3 (GPLv3).

👨‍💻 Author

Timothy Miller

View my GitHub profile 💡

View my personal website 💻

amd64
arm64
armv7
cloudflare-ddns
ddns-client
docker-image
dynamic-dns
ipv6
raspberry-pi
rust
self-hosted

timothymiller/cloudflare-ddns

🦀 Rust based dynamic DNS (DDNS) updater for Cloudflare

Rust

4,534

216 commits

updated Oct 11, 2026

See the code

README

Cloudflare DDNS

🌍 Cloudflare DDNS

Access your home network remotely via a custom domain name without a static IP!

A feature-complete dynamic DNS client for Cloudflare, written in Rust. The smallest and most memory-efficient open-source Cloudflare DDNS Docker image available — ~1.1 MB image size and ~3.5 MB RAM at runtime, smaller and leaner than Go-based alternatives. Built as a fully static binary from scratch with zero runtime dependencies.

Configure everything with environment variables. Supports notifications, heartbeat monitoring, WAF list management, flexible scheduling, and more.

Docker Pulls Docker Image Size

✨ Features

  • 🔍 Multiple IP detection providers — Cloudflare Trace, Cloudflare DNS-over-HTTPS, ipify, local interface, custom URL, or static IPs
  • 📡 IPv4 and IPv6 — Full dual-stack support with independent provider configuration
  • 🌐 Multiple domains and zones — Update any number of domains across multiple Cloudflare zones
  • 🃏 Wildcard domains — Support for *.example.com records
  • 🌍 Internationalized domain names — Full IDN/punycode support (e.g. münchen.de)
  • 🛡️ WAF list management — Automatically update Cloudflare WAF IP lists
  • 🔔 Notifications — Shoutrrr-compatible notifications (Discord, Slack, Telegram, Gotify, Pushover, Zulip, generic webhooks)
  • 💓 Heartbeat monitoring — Healthchecks.io and Uptime Kuma integration
  • ⏱️ Cron scheduling — Flexible update intervals via cron expressions
  • 🧪 Dry-run mode — Preview changes without modifying DNS records
  • 🧹 Graceful shutdown — Signal handling (SIGINT/SIGTERM) with optional DNS record cleanup
  • 💬 Record comments — Tag managed records with comments for identification
  • 🎯 Managed record regex — Control which records the tool manages via regex matching
  • 🎨 Pretty output with emoji — Configurable emoji and verbosity levels
  • 🔒 Zero-log IP detection — Uses Cloudflare's cdn-cgi/trace by default
  • 🏠 CGNAT-aware local detection — Filters out shared address space (100.64.0.0/10) and private ranges
  • 🚫 Cloudflare IP rejection — Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates
  • 🛟 Outage-proof updates — Transient IP detection failures never delete or overwrite existing DNS records
  • 🤏 Tiny static binary — ~1.1 MB Docker image built from scratch, zero runtime dependencies

🚀 Quick Start

docker run -d \
  --name cloudflare-ddns \
  --restart unless-stopped \
  --network host \
  -e CLOUDFLARE_API_TOKEN=your-api-token \
  -e DOMAINS=example.com,www.example.com \
  timothyjmiller/cloudflare-ddns:latest

That's it. The container detects your public IP and updates the DNS records for your domains every 5 minutes.

⚠️ --network host is required to detect IPv6 addresses. If you only need IPv4, you can omit it and set IP6_PROVIDER=none.

🔑 Authentication

VariableDescription
CLOUDFLARE_API_TOKENAPI token with "Edit DNS" capability
CLOUDFLARE_API_TOKEN_FILEPath to a file containing the API token (Docker secrets compatible)

To generate an API token, go to your Cloudflare Profile and create a token capable of Edit DNS.

🌐 Domains

VariableDescription
DOMAINSComma-separated list of domains to update for both IPv4 and IPv6
IP4_DOMAINSComma-separated list of IPv4-only domains
IP6_DOMAINSComma-separated list of IPv6-only domains

Wildcard domains are supported: *.example.com

At least one of DOMAINS, IP4_DOMAINS, IP6_DOMAINS, or WAF_LISTS must be set.

🔍 IP Detection Providers

VariableDefaultDescription
IP4_PROVIDERipifyIPv4 detection method
IP6_PROVIDERcloudflare.traceIPv6 detection method

Available providers:

ProviderDescription
cloudflare.trace🔒 Cloudflare's /cdn-cgi/trace endpoint (default, zero-log)
cloudflare.doh🌐 Cloudflare DNS-over-HTTPS (whoami.cloudflare TXT query)
ipify🌎 ipify.org API
local🏠 Local IP via system routing table (no network traffic, CGNAT-aware)
local.iface:<name>🔌 IP from a specific network interface (e.g., local.iface:eth0)
local.iface.stable:<name>🔌 Preferred stable IPv6 address from a Linux network interface, excluding temporary/deprecated addresses
url:<url>🔗 Custom HTTP(S) endpoint that returns an IP address
literal:<ips>📌 Static IP addresses (comma-separated)
none🚫 Disable this IP type

🚫 Cloudflare IP Rejection

VariableDefaultDescription
REJECT_CLOUDFLARE_IPStrueReject detected IPs that fall within Cloudflare's IP ranges

Some IP detection providers occasionally return a Cloudflare anycast IP instead of your real public IP. When this happens, your DNS record gets updated to point at Cloudflare infrastructure rather than your actual address.

By default, each update cycle fetches Cloudflare's published IP ranges and skips any detected IP that falls within them. A warning is logged for every rejected IP. If the ranges cannot be fetched, the update is skipped entirely to prevent writing a Cloudflare IP.

To disable this protection, set REJECT_CLOUDFLARE_IPS=false.

⏱️ Scheduling

VariableDefaultDescription
UPDATE_CRON@every 5mUpdate schedule
UPDATE_ON_STARTtrueRun an update immediately on startup
DELETE_ON_STOPfalseDelete managed DNS records on shutdown
DELETE_ON_FAILUREfalseDelete managed DNS records when a provider definitively reports no address of that family (see below)

Schedule formats:

  • @every 5m — Every 5 minutes
  • @every 1h — Every hour
  • @every 30s — Every 30 seconds
  • @once — Run once and exit

When UPDATE_CRON=@once, UPDATE_ON_START must be true and DELETE_ON_STOP must be false.

🛟 Detection Failure Behavior

A failed IP detection never breaks your DNS. Two cases are distinguished:

  • Transient failure — a network-based provider (cloudflare.trace, cloudflare.doh, ipify, url:) errored, or all detected IPs were rejected as Cloudflare IPs. The real IP is unknown, so the update is skipped and existing DNS records and WAF list items are always preserved, regardless of DELETE_ON_FAILURE. If detection fails for one address family, WAF list updates are skipped entirely so the failed family's IPs aren't stripped from the list.
  • Definitive absence — a deterministic provider (none, literal:, local, local.iface:) reports that the host has no address of that family. With DELETE_ON_FAILURE=true the managed records for that family are deleted; with the default false the update is skipped and existing records are preserved.

📝 DNS Record Settings

VariableDefaultDescription
TTL1 (auto)DNS record TTL in seconds (1=auto, or 30-86400)
PROXIEDfalseExpression controlling which domains are proxied through Cloudflare
RECORD_COMMENT(empty)Comment attached to managed DNS records
MANAGED_RECORDS_COMMENT_REGEX(empty)Regex to identify which records are managed (empty = all)

The PROXIED variable supports boolean expressions:

ExpressionMeaning
true☁️ Proxy all domains
false🔓 Don't proxy any domains
is(example.com)🎯 Only proxy example.com
sub(cdn.example.com)🌳 Proxy cdn.example.com and its subdomains
is(a.com) || is(b.com)🔀 Proxy a.com or b.com
!is(vpn.example.com)🚫 Proxy everything except vpn.example.com

Operators: is(), sub(), !, &&, ||, ()

🛡️ WAF Lists

VariableDefaultDescription
WAF_LISTS(empty)Comma-separated WAF lists in account-id/list-name format
WAF_LIST_DESCRIPTION(empty)Description for managed WAF lists
WAF_LIST_ITEM_COMMENT(empty)Comment for WAF list items
MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX(empty)Regex to identify managed WAF list items

WAF list names must match the pattern [a-z0-9_]+.

🔔 Notifications (Shoutrrr)

VariableDescription
SHOUTRRRNewline-separated list of notification service URLs

Supported services:

ServiceURL format
💬 Discorddiscord://token@webhook-id
📨 Slackslack://token-a/token-b/token-c
✈️ Telegramtelegram://bot-token@telegram?chats=chat-id
📡 Gotifygotify://host/path?token=app-token
📲 Pushoverpushover://user-key@api-token
💬 Zulipzulip://bot-mail:bot-key@host/?stream=stream-name&topic=topic-name
🌐 Generic webhookgeneric://host/path or generic+https://host/path

For Zulip, the @ in the bot email may be written literally or percent-encoded (%40), and topic is optional (defaults to Cloudflare DDNS).

Generic webhooks send a JSON payload of {"message": "..."}. Use ?messagekey=<field> to rename the field, e.g. generic://host/path?messagekey=text for services expecting Slack-style payloads.

Notifications are sent when DNS records are updated, created, deleted, or when errors occur.

💓 Heartbeat Monitoring

VariableDescription
HEALTHCHECKSHealthchecks.io ping URL
UPTIMEKUMAUptime Kuma push URL

Heartbeats are sent after each update cycle. On failure, a fail signal is sent. On shutdown, an exit signal is sent.

⏳ Timeouts

VariableDefaultDescription
DETECTION_TIMEOUT5sTimeout for IP detection requests
UPDATE_TIMEOUT30sTimeout for Cloudflare API requests

🖥️ Output

VariableDefaultDescription
EMOJItrueUse emoji in output messages
QUIETfalseSuppress informational output

🏁 CLI Flags

FlagDescription
--dry-run🧪 Preview changes without modifying DNS records
--repeat🔁 Run continuously (legacy config mode only; env var mode uses UPDATE_CRON)

📋 All Environment Variables

VariableDefaultDescription
CLOUDFLARE_API_TOKEN—🔑 API token
CLOUDFLARE_API_TOKEN_FILE—📄 Path to API token file
DOMAINS—🌐 Domains for both IPv4 and IPv6
IP4_DOMAINS—4️⃣ IPv4-only domains
IP6_DOMAINS—6️⃣ IPv6-only domains
IP4_PROVIDERipify🔍 IPv4 detection provider
IP6_PROVIDERcloudflare.trace🔍 IPv6 detection provider
UPDATE_CRON@every 5m⏱️ Update schedule
UPDATE_ON_STARTtrue🚀 Update on startup
DELETE_ON_STOPfalse🧹 Delete records on shutdown
DELETE_ON_FAILUREfalse🧹 Delete records when provider definitively reports no IP
TTL1⏳ DNS record TTL
PROXIEDfalse☁️ Proxied expression
RECORD_COMMENT—💬 DNS record comment
MANAGED_RECORDS_COMMENT_REGEX—🎯 Managed records regex
WAF_LISTS—🛡️ WAF lists to manage
WAF_LIST_DESCRIPTION—📝 WAF list description
WAF_LIST_ITEM_COMMENT—💬 WAF list item comment
MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX—🎯 Managed WAF items regex
DETECTION_TIMEOUT5s⏳ IP detection timeout
UPDATE_TIMEOUT30s⏳ API request timeout
REJECT_CLOUDFLARE_IPStrue🚫 Reject Cloudflare anycast IPs
EMOJItrue🎨 Enable emoji output
QUIETfalse🤫 Suppress info output
HEALTHCHECKS—💓 Healthchecks.io URL
UPTIMEKUMA—💓 Uptime Kuma URL
SHOUTRRR—🔔 Notification URLs (newline-separated)

🚢 Deployment

🐳 Docker Compose

version: '3.9'
services:
  cloudflare-ddns:
    image: timothyjmiller/cloudflare-ddns:latest
    container_name: cloudflare-ddns
    security_opt:
      - no-new-privileges:true
    network_mode: 'host'
    environment:
      - CLOUDFLARE_API_TOKEN=your-api-token
      - DOMAINS=example.com,www.example.com
      - PROXIED=true
      - IP6_PROVIDER=none
      - HEALTHCHECKS=https://hc-ping.com/your-uuid
    restart: unless-stopped

⚠️ Docker requires network_mode: host to access the IPv6 public address.

☸️ Kubernetes

The chart is published to GitHub Container Registry as an OCI artifact.

1. Quick install (single domain):

helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
  --namespace ddns --create-namespace \
  --set cloudflare.apiToken=your-api-token \
  --set domains=example.com

For multiple domains, use a values.yaml file — Helm's --set treats commas as value-list separators.

2. Or use a values.yaml for a full configuration:

cloudflare:
  apiToken: your-api-token   # or use existingSecret

domains: example.com,www.example.com
ip4Provider: cloudflare.trace
ip6Provider: cloudflare.trace   # set to none if IPv6 is not needed

proxied: "true"
updateCron: "@every 5m"

healthchecks: https://hc-ping.com/your-uuid   # optional
helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
  --namespace ddns --create-namespace \
  -f values.yaml

Upgrade:

helm upgrade cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
  --namespace ddns -f values.yaml

Uninstall:

helm uninstall cloudflare-ddns --namespace ddns

⚠️ hostNetwork: true is set by default so the pod can detect IPv6 addresses. Disable it with --set hostNetwork=false if you only need IPv4.

Key values:

ValueDefaultDescription
cloudflare.apiToken""API token (required unless existingSecret is set)
cloudflare.existingSecret""Use a pre-existing Secret instead
domains""Comma-separated domains for A+AAAA records
ip4Domains / ip6Domains""IPv4-only or IPv6-only domains
ip4Provider / ip6Providercloudflare.traceIP detection provider
proxied"false"Proxy through Cloudflare (boolean expression)
updateCron@every 5mUpdate schedule
hostNetworktrueRequired for local IPv6 detection
extraEnv[]Additional env vars for advanced settings

See charts/cloudflare-ddns/values.yaml for all options.

Raw manifest (legacy)

The k8s/cloudflare-ddns.yml manifest uses the legacy JSON config mode. Create a secret containing your config.json and apply:

kubectl create secret generic config-cloudflare-ddns --from-file=config.json -n ddns
kubectl apply -f k8s/cloudflare-ddns.yml

🐧 Linux + Systemd

  1. Build and install:
cargo build --release
sudo cp target/release/cloudflare-ddns /usr/local/bin/
  1. Copy the systemd units from the systemd/ directory:
sudo cp systemd/cloudflare-ddns.service /etc/systemd/system/
sudo cp systemd/cloudflare-ddns.timer /etc/systemd/system/
  1. Place a config.json at /etc/cloudflare-ddns/config.json (the systemd service uses legacy config mode).

  2. Enable the timer:

sudo systemctl enable --now cloudflare-ddns.timer

The timer runs the service every 15 minutes (configurable in cloudflare-ddns.timer).

🔨 Building from Source

cargo build --release

The binary is at target/release/cloudflare-ddns.

🐳 Docker builds

# Single architecture (linux/amd64)
./scripts/docker-build.sh

# Multi-architecture (linux/amd64, linux/arm64, linux/ppc64le)
./scripts/docker-build-all.sh

💻 Supported Platforms

  • 🐳 Docker (amd64, arm64, ppc64le)
  • 🐙 Docker Compose
  • ☸️ Kubernetes + Helm (OCI chart at ghcr.io/timothymiller/cloudflare-ddns)
  • 🐧 Systemd
  • 🍎 macOS, 🪟 Windows, 🐧 Linux — anywhere Rust compiles

📁 Legacy JSON Config File

For backwards compatibility, cloudflare-ddns still supports configuration via a config.json file. This mode is used automatically when no CLOUDFLARE_API_TOKEN environment variable is set.

🚀 Quick Start

cp config-example.json config.json
# Edit config.json with your values
cloudflare-ddns

🔑 Authentication

Use either an API token (recommended) or a legacy API key:

"authentication": {
  "api_token": "Your cloudflare API token with Edit DNS capability"
}

Or with a legacy API key:

"authentication": {
  "api_key": {
    "api_key": "Your cloudflare API Key",
    "account_email": "The email address you use to sign in to cloudflare"
  }
}

📡 IPv4 and IPv6

Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable the interface that is not accessible:

"a": true,
"aaaa": true

⚙️ Config Options

By default, the legacy config file is loaded from ./config.json. Set the CONFIG_PATH environment variable to change the directory:

CONFIG_PATH=/etc/cloudflare-ddns cloudflare-ddns

Or in Docker Compose:

environment:
  - CONFIG_PATH=/config
volumes:
  - /your/path/config.json:/config/config.json
KeyTypeDefaultDescription
cloudflarearrayrequiredList of zone configurations
abooltrueEnable IPv4 (A record) updates
aaaabooltrueEnable IPv6 (AAAA record) updates
purgeUnknownRecordsboolfalseDelete stale/duplicate DNS records
ttlint300DNS record TTL in seconds (30-86400, values < 30 become auto)
ip4_providerstring"cloudflare.trace"IPv4 detection provider (same values as IP4_PROVIDER env var)
ip6_providerstring"cloudflare.trace"IPv6 detection provider (same values as IP6_PROVIDER env var)

🚫 Cloudflare IP Rejection (Legacy Mode)

Cloudflare IP rejection is enabled by default in legacy mode too. To disable it, set REJECT_CLOUDFLARE_IPS=false alongside your config.json:

REJECT_CLOUDFLARE_IPS=false cloudflare-ddns

Or in Docker Compose:

environment:
  - REJECT_CLOUDFLARE_IPS=false
volumes:
  - ./config.json:/config.json

🔍 IP Detection (Legacy Mode)

Legacy mode now uses the same shared provider abstraction as environment variable mode. By default it uses the cloudflare.trace provider, which builds an IP-family-bound HTTP client (0.0.0.0 for IPv4, [::] for IPv6) to guarantee the correct address family on dual-stack hosts.

You can override the detection method per address family with ip4_provider and ip6_provider in your config.json. Supported values are the same as the IP4_PROVIDER / IP6_PROVIDER environment variables: cloudflare.trace, cloudflare.doh, ipify, local, local.iface:<name>, local.iface.stable:<name>, url:<https://...>, none.

Set a provider to "none" to disable detection for that address family (overrides a/aaaa):

{
  "a": true,
  "aaaa": true,
  "ip4_provider": "cloudflare.trace",
  "ip6_provider": "none"
}

Each zone entry contains:

KeyTypeDescription
authenticationobjectAPI token or API key credentials
zone_idstringCloudflare zone ID (found in zone dashboard)
subdomainsarraySubdomain entries to update
proxiedboolDefault proxied status for subdomains in this zone

Subdomain entries can be a simple string or a detailed object:

"subdomains": [
  "",
  "@",
  "www",
  { "name": "vpn", "proxied": true }
]

Use "" or "@" for the root domain. Do not include the base domain name.

🔄 Environment Variable Substitution

In the legacy config file, values can reference environment variables with the CF_DDNS_ prefix:

{
  "cloudflare": [{
    "authentication": {
      "api_token": "${CF_DDNS_API_TOKEN}"
    },
    ...
  }]
}

📠 Example: Multiple Subdomains

{
  "cloudflare": [
    {
      "authentication": {
        "api_token": "your-api-token"
      },
      "zone_id": "your_zone_id",
      "subdomains": [
        { "name": "", "proxied": true },
        { "name": "www", "proxied": true },
        { "name": "vpn", "proxied": false }
      ]
    }
  ],
  "a": true,
  "aaaa": true,
  "purgeUnknownRecords": false,
  "ttl": 300
}

🌐 Example: Multiple Zones

{
  "cloudflare": [
    {
      "authentication": { "api_token": "your-api-token" },
      "zone_id": "first_zone_id",
      "subdomains": [
        { "name": "", "proxied": false }
      ]
    },
    {
      "authentication": { "api_token": "your-api-token" },
      "zone_id": "second_zone_id",
      "subdomains": [
        { "name": "", "proxied": false }
      ]
    }
  ],
  "a": true,
  "aaaa": true,
  "purgeUnknownRecords": false
}

🐳 Docker Compose (legacy config file)

version: '3.9'
services:
  cloudflare-ddns:
    image: timothyjmiller/cloudflare-ddns:latest
    container_name: cloudflare-ddns
    security_opt:
      - no-new-privileges:true
    network_mode: 'host'
    volumes:
      - /YOUR/PATH/HERE/config.json:/config.json
    restart: unless-stopped

🏁 Legacy CLI Flags

In legacy config mode, use --repeat to run continuously (the TTL value is used as the update interval):

cloudflare-ddns --repeat
cloudflare-ddns --repeat --dry-run

📜 License

This project is licensed under the GNU General Public License, version 3 (GPLv3).

👨‍💻 Author

Timothy Miller

View my GitHub profile 💡

View my personal website 💻

amd64
arm64
armv7
cloudflare-ddns
ddns-client
docker-image
dynamic-dns
ipv6
raspberry-pi
rust
self-hosted

Significant stargazers

(top 24 of 31)

Pascal Hartig

2,293 followers · starred Jul 2024

Ivan Kravets

821 followers · starred Sep 2026

Devin Buhl

917 followers · starred Nov 2024

Xing Han Lu

430 followers · starred Jul 2023