cloudflare-ddns
π¦ Rust based dynamic DNS updater for Cloudflare
5M+
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.
*.example.com recordsmΓΌnchen.de)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 hostis required to detect IPv6 addresses. If you only need IPv4, you can omit it and setIP6_PROVIDER=none.
| Variable | Description |
|---|---|
CLOUDFLARE_API_TOKEN | API token with "Edit DNS" capability |
CLOUDFLARE_API_TOKEN_FILE | Path 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.
| Variable | Description |
|---|---|
DOMAINS | Comma-separated list of domains to update for both IPv4 and IPv6 |
IP4_DOMAINS | Comma-separated list of IPv4-only domains |
IP6_DOMAINS | Comma-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.
| Variable | Default | Description |
|---|---|---|
IP4_PROVIDER | ipify | IPv4 detection method |
IP6_PROVIDER | cloudflare.trace | IPv6 detection method |
Available providers:
| Provider | Description |
|---|---|
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) |
url:<url> | π Custom HTTP(S) endpoint that returns an IP address |
literal:<ips> | π Static IP addresses (comma-separated) |
none | π« Disable this IP type |
| Variable | Default | Description |
|---|---|---|
REJECT_CLOUDFLARE_IPS | false | Reject 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.
Setting REJECT_CLOUDFLARE_IPS=true prevents this. 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.
| Variable | Default | Description |
|---|---|---|
UPDATE_CRON | @every 5m | Update schedule |
UPDATE_ON_START | true | Run an update immediately on startup |
DELETE_ON_STOP | false | Delete managed DNS records on shutdown |
Schedule formats:
@every 5m β Every 5 minutes@every 1h β Every hour@every 30s β Every 30 seconds@once β Run once and exitWhen UPDATE_CRON=@once, UPDATE_ON_START must be true and DELETE_ON_STOP must be false.
| Variable | Default | Description |
|---|---|---|
TTL | 1 (auto) | DNS record TTL in seconds (1=auto, or 30-86400) |
PROXIED | false | Expression 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:
| Expression | Meaning |
|---|---|
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(), !, &&, ||, ()
| Variable | Default | Description |
|---|---|---|
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_]+.
| Variable | Description |
|---|---|
SHOUTRRR | Newline-separated list of notification service URLs |
Supported services:
| Service | URL format |
|---|---|
| π¬ Discord | discord://token@webhook-id |
| π¨ Slack | slack://token-a/token-b/token-c |
| βοΈ Telegram | telegram://bot-token@telegram?chats=chat-id |
| π‘ Gotify | gotify://host/path?token=app-token |
| π² Pushover | pushover://user-key@api-token |
| π Generic webhook | generic://host/path or generic+https://host/path |
Notifications are sent when DNS records are updated, created, deleted, or when errors occur.
| Variable | Description |
|---|---|
HEALTHCHECKS | Healthchecks.io ping URL |
UPTIMEKUMA | Uptime Kuma push URL |
Heartbeats are sent after each update cycle. On failure, a fail signal is sent. On shutdown, an exit signal is sent.
| Variable | Default | Description |
|---|---|---|
DETECTION_TIMEOUT | 5s | Timeout for IP detection requests |
UPDATE_TIMEOUT | 30s | Timeout for Cloudflare API requests |
| Variable | Default | Description |
|---|---|---|
EMOJI | true | Use emoji in output messages |
QUIET | false | Suppress informational output |
| Flag | Description |
|---|---|
--dry-run | π§ͺ Preview changes without modifying DNS records |
--repeat | π Run continuously (legacy config mode only; env var mode uses UPDATE_CRON) |
| Variable | Default | Description |
|---|---|---|
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_PROVIDER | ipify | π IPv4 detection provider |
IP6_PROVIDER | cloudflare.trace | π IPv6 detection provider |
UPDATE_CRON | @every 5m | β±οΈ Update schedule |
UPDATE_ON_START | true | π Update on startup |
DELETE_ON_STOP | false | π§Ή Delete records on shutdown |
TTL | 1 | β³ DNS record TTL |
PROXIED | false | βοΈ 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_TIMEOUT | 5s | β³ IP detection timeout |
UPDATE_TIMEOUT | 30s | β³ API request timeout |
REJECT_CLOUDFLARE_IPS | false | π« Reject Cloudflare anycast IPs |
EMOJI | true | π¨ Enable emoji output |
QUIET | false | π€« Suppress info output |
HEALTHCHECKS | β | π Healthchecks.io URL |
UPTIMEKUMA | β | π Uptime Kuma URL |
SHOUTRRR | β | π Notification URLs (newline-separated) |
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: hostto access the IPv6 public address.
The included 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
cargo build --release
sudo cp target/release/cloudflare-ddns /usr/local/bin/
systemd/ directory:sudo cp systemd/cloudflare-ddns.service /etc/systemd/system/
sudo cp systemd/cloudflare-ddns.timer /etc/systemd/system/
Place a config.json at /etc/cloudflare-ddns/config.json (the systemd service uses legacy config mode).
Enable the timer:
sudo systemctl enable --now cloudflare-ddns.timer
The timer runs the service every 15 minutes (configurable in cloudflare-ddns.timer).
cargo build --release
The binary is at target/release/cloudflare-ddns.
# Single architecture (linux/amd64)
./scripts/docker-build.sh
# Multi-architecture (linux/amd64, linux/arm64, linux/ppc64le)
./scripts/docker-build-all.sh
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.
cp config-example.json config.json
# Edit config.json with your values
cloudflare-ddns
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"
}
}
Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable the interface that is not accessible:
"a": true,
"aaaa": true
| Key | Type | Default | Description |
|---|---|---|---|
cloudflare | array | required | List of zone configurations |
a | bool | true | Enable IPv4 (A record) updates |
aaaa | bool | true | Enable IPv6 (AAAA record) updates |
purgeUnknownRecords | bool | false | Delete stale/duplicate DNS records |
ttl | int | 300 | DNS record TTL in seconds (30-86400, values < 30 become auto) |
Each zone entry contains:
| Key | Type | Description |
|---|---|---|
authentication | object | API token or API key credentials |
zone_id | string | Cloudflare zone ID (found in zone dashboard) |
subdomains | array | Subdomain entries to update |
proxied | bool | Default 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.
In the legacy config file, values can reference environment variables with the CF_DDNS_ prefix:
{
"cloudflare": [{
"authentication": {
"api_token": "${CF_DDNS_API_TOKEN}"
},
...
}]
}
{
"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
}
{
"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
}
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
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
This project is licensed under the GNU General Public License, version 3 (GPLv3).
Timothy Miller
Content type
Image
Digest
sha256:37c99677eβ¦
Size
1.1 MB
Last updated
about 2 months ago
docker pull timothyjmiller/cloudflare-ddns:2.1.2