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.9 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.
- π 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.comrecords - π 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, 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
- π€ Tiny static binary β ~1.9 MB Docker image built from scratch, zero runtime dependencies
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:latestThat'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 |
|---|---|---|
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 exit
When 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://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 |
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 requiresnetwork_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- Build and install:
cargo build --release
sudo cp target/release/cloudflare-ddns /usr/local/bin/- 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/-
Place a
config.jsonat/etc/cloudflare-ddns/config.json(the systemd service uses legacy config mode). -
Enable the timer:
sudo systemctl enable --now cloudflare-ddns.timerThe timer runs the service every 15 minutes (configurable in cloudflare-ddns.timer).
cargo build --releaseThe 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- π³ Docker (amd64, arm64, ppc64le)
- π Docker Compose
- βΈοΈ Kubernetes
- π§ Systemd
- π macOS, πͺ Windows, π§ Linux β anywhere Rust compiles
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-ddnsUse 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-stoppedIn 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- π Cloudflare API token
- π Cloudflare zone ID
- π Cloudflare zone DNS record ID
This project is licensed under the GNU General Public License, version 3 (GPLv3).
Timothy Miller
