A feature-rich and robust Cloudflare DDNS updater with a small footprint. The program will detect your machine's public IP addresses and update DNS records using the Cloudflare API.
- 🤏 The Docker image takes less than 5 MB after compression.
- 🔁 The Go runtime re-uses existing HTTP connections.
- 🗃️ Cloudflare API responses are cached to reduce the API usage.
- 😌 You can simply list domains (e.g.,
www.a.org, hello.io
) without knowing their DNS zones. - 🌍 Internationalized domain names (e.g.,
🐱.example.org
and日本。co。jp
) are fully supported. - 🃏 Wildcard domains (e.g.,
*.example.org
) are also supported. - 🕹️ You can toggle IPv4 (
A
records) and IPv6 (AAAA
records) for each domain.
- 😶🌫️ You can toggle Cloudflare proxying for each domain.
- 📝 You can set DNS record comments (and probably record tags soon).
- 📜 The updater can maintain custom lists of detected IP addresses. These lists can then be referenced in Web Application Firewall (WAF) rules.
- 🩺 The updater can report to Healthchecks or Uptime Kuma so that you receive notifications when it fails to update IP addresses.
- 📣 The updater can also actively update you via any service supported by the shoutrrr library, including emails, major notification services, major messaging platforms, and generic webhooks.
By default, public IP addresses are obtained via Cloudflare debugging page. This minimizes the impact on privacy because we are already using the Cloudflare API to update DNS records. Moreover, if Cloudflare servers are not reachable, chances are you cannot update DNS records anyways.
-
🛡️ The updater uses only HTTPS or DNS over HTTPS to detect IP addresses. This makes it harder for someone else to trick the updater into updating your DNS records with wrong IP addresses. See the Security Model for more information.
-
✍️ You can verify the Docker images were built from this repository using the cosign tool (click to expand)
cosign verify favonia/cloudflare-ddns:latest \ --certificate-identity-regexp https://github.com/favonia/cloudflare-ddns/ \ --certificate-oidc-issuer https://token.actions.githubusercontent.com
Note: this only proves that the Docker image is from this repository, assuming that no one hacks into GitHub or the repository. It does not prove that the code itself is secure.
-
📚 The updater uses only established open-source Go libraries (click to expand)
- cloudflare-go:
The official Go binding of Cloudflare API v4. - cron:
Parsing of Cron expressions. - go-retryablehttp:
HTTP clients with automatic retries and exponential backoff. - go-querystring:
A library to construct URL query parameters. - shoutrrr:
A notification library for sending general updates. - ttlcache:
In-memory cache to hold Cloudflare API responses. - mock (for testing only):
A comprehensive, semi-official framework for mocking. - testify (for testing only):
A comprehensive tool set for testing Go programs.
- cloudflare-go:
(Click to expand the following items.)
🐋 Directly run the Docker image.
docker run \
--network host \
-e CF_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN \
-e DOMAINS=example.org,www.example.org,example.io \
-e PROXIED=true \
favonia/cloudflare-ddns:latest
🧬 Directly run the updater from its source.
You need the Go tool to run the updater from its source.
CF_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN \
DOMAINS=example.org,www.example.org,example.io \
PROXIED=true \
go run github.com/favonia/cloudflare-ddns/cmd/ddns@latest
Incorporate the following fragment into the compose file (typically docker-compose.yml
or docker-compose.yaml
). The template may look a bit scary, but only because it includes various optional flags for extra security protection.
services:
cloudflare-ddns:
image: favonia/cloudflare-ddns:latest
# Choose the appropriate tag based on your need:
# - "latest" for the latest stable version (which could become 2.x.y
# in the future and break things)
# - "1" for the latest stable version whose major version is 1
# - "1.x.y" to pin the specific version 1.x.y
network_mode: host
# This bypasses network isolation and makes IPv6 easier (optional; see below)
restart: always
# Restart the updater after reboot
user: "1000:1000"
# Run the updater with specific user and group IDs (in that order).
# You can change the two numbers based on your need.
read_only: true
# Make the container filesystem read-only (optional but recommended)
cap_drop: [all]
# Drop all Linux capabilities (optional but recommended)
security_opt: [no-new-privileges:true]
# Another protection to restrict superuser privileges (optional but recommended)
environment:
- CF_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN
# Your Cloudflare API token
- DOMAINS=example.org,www.example.org,example.io
# Your domains (separated by commas)
- PROXIED=true
# Tell Cloudflare to cache webpages and hide your IP (optional)
(Click to expand the following important tips.)
🔑 CF_API_TOKEN
is your Cloudflare API token
The value of CF_API_TOKEN
should be an API token (not an API key), which can be obtained from the API Tokens page. (The less secure API key authentication is deliberately not supported.)
- To update only DNS records, use the Edit zone DNS template to create a token.
- To update only WAF lists, choose Create Custom Token and then add the Account - Account Filter Lists - Edit permission to create a token.
- To update DNS records and WAF lists, use the Edit zone DNS template and then add the Account - Account Filter Lists - Edit permission when creating the token.
You can also adjust the permissions of existing tokens at any time!
📍 DOMAINS
is the list of domains to update
The value of DOMAINS
should be a list of fully qualified domain names (FQDNs) separated by commas. For example, DOMAINS=example.org,www.example.org,example.io
instructs the updater to manage the domains example.org
, www.example.org
, and example.io
. These domains do not have to share the same DNS zone---the updater will take care of the DNS zones behind the scene.
🚨 Remove PROXIED=true
if you are not running a web server
The setting PROXIED=true
instructs Cloudflare to cache webpages and hide your IP addresses. If you wish to bypass that and expose your actual IP addresses, remove PROXIED=true
. If your traffic is not HTTP(S), then Cloudflare cannot proxy it and you should probably turn off the proxying by removing PROXIED=true
. The default value of PROXIED
is false
.
📴 Add IP6_PROVIDER=none
if you want to disable IPv6 completely
The updater, by default, will attempt to update DNS records for both IPv4 and IPv6, and there is no harm in leaving the automatic detection on even if your network does not work for one of them. However, if you want to disable IPv6 entirely (perhaps to avoid seeing the detection errors), add IP6_PROVIDER=none
.
📡 Expand this if you want IPv6 without bypassing network isolation (without network_mode: host
)
The easiest way to enable IPv6 is to use network_mode: host
so that the updater can access the host IPv6 network directly. This has the downside of bypassing the network isolation. If you wish to keep the updater isolated from the host network, remove network_mode: host
and follow the steps in the official Docker documentation to enable IPv6. Do use newer versions of Docker that come with much better IPv6 support!
🛡️ Change user: "1000:1000"
to the user and group IDs you want to use
Change 1000:1000
to USER:GROUP
for the USER
and GROUP
IDs you wish to use to run the updater. The settings cap_drop
, read_only
, and no-new-privileges
in the template provide additional protection, especially when you run the container as a non-superuser.
docker-compose pull cloudflare-ddns
docker-compose up --detach --build cloudflare-ddns
(Click to expand the following items.)
❔ I simulated an IP address change by editing the DNS records, but the updater never picked it up!
Please rest assured that the updater is working as expected. It will update the DNS records immediately for a real IP change. Here is a detailed explanation. There are two causes of an IP mismatch:
- A change of your actual IP address (a real change), or
- A change of the IP address in the DNS records (a simulated change).
The updater assumes no one will actively change the DNS records. In other words, it assumes simulated changes will not happen. It thus caches the DNS records and cannot detect your simulated changes. However, when your actual IP address changes, the updater will immediately update the DNS records. Also, the updater will eventually check the DNS records and detect simulated changes after CACHE_EXPIRATION
(six hours by default) has passed.
If you really wish to test the updater with simulated IP changes in the DNS records, you can set CACHE_EXPIRATION=1ns
(all cache expiring in one nanosecond), effectively disabling the caching. However, it is recommended to keep the default value (six hours) to reduce your network traffic.
❔ How can I see the timestamps of the IP checks and/or updates?
The updater does not itself add timestamps because all major systems already timestamp everything:
- If you are using Docker Compose, Kubernetes, or Docker directly, add the option
--timestamps
when viewing the logs. - If you are using Portainer, enable “Show timestamp” when viewing the logs.
❔ Why did the updater detect a public IP address different from the WAN IP address on my router?
Is your “public” IP address on your router between 100.64.0.0
and 100.127.255.255
? If so, you are within your ISP’s CGNAT (Carrier-grade NAT). In practice, there is no way for DDNS to work with CGNAT, because your ISP does not give you a real public IP address, nor does it allow you to forward IP packages to your router using cool protocols such as Port Control Protocol. You have to give up DDNS or switch to another ISP. You may consider other services such as Cloudflare Tunnel that can work around CGNAT.
The emoji “🧪” indicates experimental features and the emoji “🤖” indicates technical details.
(Click to expand the following items.)
🔑 The Cloudflare API token
Exactly one of the following variables should be set.
Name | Meaning |
---|---|
CF_API_TOKEN |
The Cloudflare API token to access the Cloudflare API |
CF_API_TOKEN_FILE |
A path to a file that contains the Cloudflare API token to access the Cloudflare API |
- 🔑 To update DNS records, the updater needs the Account - Account Filter Lists - Edit permission.
- 🔑 To manipulate WAF lists, the updater needs the Zone - DNS - Edit permission.
📍 DNS domains and WAF lists to update
You need to specify at least one thing in
DOMAINS
,IP4_DOMAINS
,IP6_DOMAINS
, or 🧪WAF_LISTS
for the updater to update.
Name | Meaning |
---|---|
DOMAINS |
Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for both A and AAAA records. Listing a domain in DOMAINS is equivalent to listing the same domain in both IP4_DOMAINS and IP6_DOMAINS . |
IP4_DOMAINS |
Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for A records |
IP6_DOMAINS |
Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for AAAA records |
🧪 WAF_LISTS |
🧪 Comma-separated references of WAF lists the updater should manage. A list reference is written in the format account-id/list-name where account-id is your account ID and list-name is the list name; it should look like 0123456789abcdef0123456789abcdef/mylist . If the referenced WAF list does not exist, the updater will try to create it. 💡 See how to find your account ID. |
🧪 The feature to manipulate WAF lists is experimental (introduced in 1.14.0) and is subject to changes. In particular, the updater currently deletes unmanaged IPs from WAF lists (e.g., deleting IPv6 addresses if you disable IPv6), but another reasonable implementation is to leave them alone. Please open a GitHub issue to provide feedback. Thanks!
🤖 Internationalized domain names are handled using the nontransitional processing (fully compatible with IDNA2008). At this point, all major browsers and whatnot have switched to the same nontransitional processing. See this useful FAQ on internationalized domain names.
🃏 What are wildcard domains?
Wildcard domains (
*.example.org
) represent all subdomains that would not exist otherwise. Therefore, if you have another subdomain entrysub.example.org
, the wildcard domain is independent of it, because it only represents the other subdomains which do not have their own entries. Also, you can only have one layer of*
---*.*.example.org
would not work.
🔍 IP address providers
Name | Meaning | Default Value |
---|---|---|
IP4_PROVIDER |
This specifies how to detect the current IPv4 address. Available providers include cloudflare.doh , cloudflare.trace , local , url:URL , and none . The special none provider disables IPv4 completely. See below for a detailed explanation. |
cloudflare.trace |
IP6_PROVIDER |
This specifies how to detect the current IPv6 address. Available providers include cloudflare.doh , cloudflare.trace , local , url:URL , and none . The special none provider disables IPv6 completely. See below for a detailed explanation. |
cloudflare.trace |
👉 The option
IP4_PROVIDER
governsA
-type DNS records and IPv4 addresses in WAF lists, while the optionIP6_PROVIDER
governsAAAA
-type DNS records and IPv6 addresses in WAF lists. The two options act independently of each other. You can specify different address providers for IPv4 and IPv6.
📡 Available IP address providers:
cloudflare.doh
Get the IP address by queryingwhoami.cloudflare.
against Cloudflare via DNS-over-HTTPS.cloudflare.trace
Get the IP address by parsing the Cloudflare debugging page. This is the default provider.local
Get the IP address via local network interfaces. When multiple local network interfaces or in general multiple IP addresses are present, the updater will use the address that would have been used for outbound UDP connections to Cloudflare servers. (No data will be transmitted.)⚠️ You need access to the host network (such asnetwork_mode: host
in Docker Compose) for this policy, for otherwise the updater will detect the addresses inside the default bridge network in Docker instead of those in the host network.url:URL
Fetch the content atURL
via the HTTP(S) protocol treat the content as the IP address. The provider format isurl:
followed by the URL. For example,IP4_PROVIDER=url:https://api4.ipify.org
will fetch the IPv4 address from https://api4.ipify.org, a server maintained by ipify. 🐞 KNOWN ISSUE: Currently, the updater will not force IPv4 or IPv6 when retrieving the IPv4 or IPv6 address atURL
. Therefore, forIP4_PROVIDER=url:URL
, the updater might use IPv6 (instead of the expected IPv4) to connect toURL
, and if the server returns an IPv6 address because of this, the updating will fail. The server atURL
must either restrict its access to the expected IP network or return a valid IP address in the expected IP network regardless of what IP network is used for connection. As a working example, https://api4.ipify.org has restricted its access to IPv4, and thus it’s impossible to use the wrong IP network (IPv6) to connect to it. 🧪 This is a known issue and may be fixed in the future.none
Stop the DNS updating for the specified IP version completely. For exampleIP4_PROVIDER=none
will disable IPv4 completely. Existing DNS records will not be removed.⚠️ The IP addresses of the disabled IP version will be removed from WAF lists; soIP4_PROVIDER=none
will remove all IPv4 addresses from all managed WAF lists. 🧪 As the support of WAF lists is experimental, this behavior is subject to changes and please provide feedback.🤖 Technical details: For the providers
cloudflare.doh
andcloudflare.trace
, the updater will connect to the servers1.1.1.1
for IPv4 and2606:4700:4700::1111
for IPv6. Since version 1.9.3, the updater will switch to1.0.0.1
for IPv4 if1.1.1.1
appears to be blocked or intercepted by your ISP or your router (which is still not uncommon).
📅 Scheduling of IP detections and updates
Name | Meaning | Default Value |
---|---|---|
CACHE_EXPIRATION |
The expiration of cached Cloudflare API responses. It can be any positive time duration accepted by time.ParseDuration, such as 1h or 10m . |
6h0m0s (6 hours) |
DELETE_ON_STOP |
Whether managed DNS records and WAF lists should be deleted on exit. It can be any boolean value accepted by strconv.ParseBool, such as true , false , 0 or 1 . 🐞🧪 KNOWN ISSUE: if a WAF list is used in a rule, you cannot delete it. In future versions, the updater may attempt to empty a list when it fails to delete it, but this has not been implemented yet. |
false |
TZ |
The timezone used for logging messages and parsing UPDATE_CRON . It can be any timezone accepted by time.LoadLocation, including any IANA Time Zone. 🤖 The pre-built Docker images come with the embedded timezone database via the time/tzdata package. |
UTC |
UPDATE_CRON |
The schedule to re-check IP addresses and update DNS records and WAF lists (if needed). The format is any cron expression accepted by the cron library or the special value @once . The special value @once means the updater will terminate immediately after updating the DNS records or WAF lists, effectively disabling the scheduling feature. 🤖 The update schedule does not take the time to update records into consideration. For example, if the schedule is @every 5m , and if the updating itself takes 2 minutes, then the actual interval between adjacent updates is 3 minutes, not 5 minutes. |
@every 5m (every 5 minutes) |
UPDATE_ON_START |
Whether to check IP addresses (and possibly update DNS records and WAF lists) immediately on start, regardless of the update schedule specified by UPDATE_CRON . It can be any boolean value accepted by strconv.ParseBool, such as true , false , 0 or 1 . |
true |
⏳ Timeouts of various operations
Name | Meaning | Default Value |
---|---|---|
DETECTION_TIMEOUT |
The timeout of each attempt to detect IP address, per IP version (IPv4 and IPv6). It can be any positive time duration accepted by time.ParseDuration, such as 1h or 10m . |
5s (5 seconds) |
UPDATE_TIMEOUT |
The timeout of each attempt to update DNS records, per domain, per record type. It can be any positive time duration accepted by time.ParseDuration, such as 1h or 10m . |
30s (30 seconds) |
🐣 Parameters of new DNS records and WAF lists
👉 The updater will preserve existing parameters (TTL, proxy states, DNS record comments, etc.). Only when it creates new DNS records and new WAF lists, the following settings will apply. To change existing parameters, you can go to your Cloudflare Dashboard and change them directly. If you think you have a use case where the updater should actively overwrite existing parameters in addition to IP addresses, please let me know. 🐞🧪 KNOWN ISSUE: existing comments attached to individual WAF list items (not WAF lists themselves) are not preserved because the Cloudflare API does not provide an easy way to update existing WAF list items. The comments will be lost when the updater deletes stale WAF list items and creates new ones.
Name | Meaning | Default Value |
---|---|---|
PROXIED |
Whether new DNS records should be proxied by Cloudflare. It can be any boolean value accepted by strconv.ParseBool, such as true , false , 0 or 1 . 🧪 It can also be a domain-dependent boolean expression as described below. |
false |
TTL |
The time-to-live (TTL) (in seconds) of new DNS records. | 1 (This means “automatic” to Cloudflare) |
RECORD_COMMENT |
The record comment of new DNS records. | "" |
🧪 WAF_LIST_DESCRIPTION |
🧪 The text description of new WAF lists. | "" |
🤖🧪 If you are an advanced user, the
PROXIED
can be a boolean expression involving domains! This allows you to enable Cloudflare proxying for some domains but not the others. Here are some example expressions:
PROXIED=is(example.org)
: proxy only the domainexample.org
PROXIED=is(example1.org) || sub(example2.org)
: proxy only the domainexample1.org
and subdomains ofexample2.org
PROXIED=!is(example.org)
: proxy every managed domain except forexample.org
PROXIED=is(example1.org) || is(example2.org) || is(example3.org)
: proxy only the domainsexample1.org
,example2.org
, andexample3.org
A boolean expression must be one of the following forms (all whitespace is ignored):
- A boolean value accepted by strconv.ParseBool, such as
t
astrue
orFALSE
asfalse
.is(d)
which matches the domaind
. Note thatis(*.a)
only matches the wildcard domain*.a
; usesub(a)
to match all subdomains ofa
(including*.a
).sub(d)
which matches subdomains ofd
, such asa.d
andb.c.d
. It does not match the domaind
itself.! e
wheree
is a boolean expression, representing logical negation ofe
.e1 || e2
wheree1
ande2
are boolean expressions, representing logical disjunction ofe1
ande2
.e1 && e2
wheree1
ande2
are boolean expressions, representing logical conjunction ofe1
ande2
.One can use parentheses to group expressions, such as
!(is(a) && (is(b) || is(c)))
. For convenience, the engine also accepts these short forms:
is(d1, d2, ..., dn)
isis(d1) || is(d2) || ... || is(dn)
sub(d1, d2, ..., dn)
issub(d1) || sub(d2) || ... || sub(dn)
For example, these two settings are equivalent:
PROXYD=is(example1.org) || is(example2.org) || is(example3.org)
PROXIED=is(example1.org,example2.org,example3.org)
👁️ Message logging options
Name | Meaning | Default Value |
---|---|---|
EMOJI |
Whether the updater should use emojis in the logging. It can be any boolean value accepted by strconv.ParseBool, such as true , false , 0 or 1 . |
true |
QUIET |
Whether the updater should reduce the logging. It can be any boolean value accepted by strconv.ParseBool, such as true , false , 0 or 1 . |
false |
📣 External notifications (Healthchecks, Uptime Kuma, and shoutrrr)
🧪 The integration with
shoutrrr
is still somewhat experimental (introduced in 1.12.0).
Name | Meaning |
---|---|
HEALTHCHECKS |
The Healthchecks ping URL to ping when the updater successfully updates IP addresses, such as https://hc-ping.com/<uuid> or https://hc-ping.com/<project-ping-key>/<name-slug> UPDATE_CRON . 🤖 The updater can work with any server following the same notification protocol, including but not limited to self-hosted instances of Healthchecks. Both UUID and Slug URLs are supported, and the updater works regardless whether the POST-only mode is enabled. |
UPTIMEKUMA |
The Uptime Kuma’s Push URL to ping when the updater successfully updates IP addresses, such as https://<host>/push/<id> . You can directly copy the “Push URL” from the Uptime Kuma configuration page. UPDATE_CRON . |
🧪 SHOUTRRR |
🧪 A list of notifications services the updater should send messages to when it updates IP addresses. The format is newline-separated shoutrrr URLs, such as discord://<token>@<id> . |
⚠️ Please note that a failure in handling IPv6 will cause the status to be reported as down even if IPv4 records are updated successfully (and similarly if IPv6 works but IPv4 fails). If your network does not support IPv6, addIP6_PROVIDER=none
to disable IPv6 completely.
If you are using Docker Compose, run docker-compose up --detach
to reload settings.
(Click to expand the following items.)
I am migrating from oznu/cloudflare-ddns (now archived)
Old Parameter | Note | |
---|---|---|
API_KEY=key |
✔️ | Use CF_API_TOKEN=key |
API_KEY_FILE=file |
✔️ | Use CF_API_TOKEN_FILE=file |
ZONE=example.org and SUBDOMAIN=sub |
✔️ | Use DOMAINS=sub.example.org directly |
PROXIED=true |
✔️ | Same (PROXIED=true ) |
RRTYPE=A |
✔️ | Both IPv4 and IPv6 are enabled by default; use IP6_PROVIDER=none to disable IPv6 |
RRTYPE=AAAA |
✔️ | Both IPv4 and IPv6 are enabled by default; use IP4_PROVIDER=none to disable IPv4 |
DELETE_ON_STOP=true |
✔️ | Same (DELETE_ON_STOP=true ) |
INTERFACE=iface |
✔️ | Not required for local providers; we can handle multiple network interfaces |
CUSTOM_LOOKUP_CMD=cmd |
❌ | There are no shells in the minimal Docker image |
DNS_SERVER=server |
❌ | Only Cloudflare is supported, except the url:URL provider via HTTP(S) |
I am migrating from timothymiller/cloudflare-ddns
Old JSON Key | Note | |
---|---|---|
cloudflare.authentication.api_token |
✔️ | Use CF_API_TOKEN=key |
cloudflare.authentication.api_key |
❌ | Please use the newer, more secure API tokens |
cloudflare.zone_id |
✔️ | Not needed; automatically retrieved from the server |
cloudflare.subdomains[].name |
✔️ | Use DOMAINS with fully qualified domain names (FQDNs) directly; for example, if your zone is example.org and your subdomain is sub , use DOMAINS=sub.example.org |
cloudflare.subdomains[].proxied |
✔️ | Write boolean expressions for PROXIED to specify per-domain settings; see above for the detailed documentation for this experimental feature |
load_balancer |
❌ | Not supported yet; please make a request if you want it |
a |
✔️ | Both IPv4 and IPv6 are enabled by default; use IP4_PROVIDER=none to disable IPv4 |
aaaa |
✔️ | Both IPv4 and IPv6 are enabled by default; use IP6_PROVIDER=none to disable IPv6 |
proxied |
✔️ | Use PROXIED=true or PROXIED=false |
purgeUnknownRecords |
❌ | The updater never deletes unmanaged DNS records |
📜 Some historical notes: This updater was originally written as a Go clone of the Python program timothymiller/cloudflare-ddns because the Python program always purged unmanaged DNS records back then and it was not configurable via environment variables. (It is still not configurable via environment variables at the time of writing.) There were feature requests to address these issues but the author timothymiller seemed to ignore them; I thus made my Go clone after unsuccessful communications. Understandably, timothymiller did not seem happy with my cloning and my other critical comments towards other aspects of the Python updater. Eventually, an option
purgeUnknownRecords
was added to the Python program to disable the unwanted purging, but my Go clone already went on its way. I believe my Go clone is now a much better choice, but my opinions are obviously biased and you should check the technical details by yourself. 😉
Questions, suggestions, feature requests, and contributions are all welcome! Feel free to open a GitHub issue.