A feature-rich and robust Cloudflare DDNS updater with a small Docker image. It detects your machine’s public IP addresses and updates DNS records through the Cloudflare API.
- 😌 You can simply list domains (e.g.,
www.a.org, hello.io) without knowing their DNS zones. - 🌍️ Internationalized domain names (e.g.,
🐱.example.organd日本。co。jp) are fully supported. - 🃏 Wildcard domains (e.g.,
*.example.org) are also supported. - 🕹️ You can toggle IPv4 (
Arecords) and IPv6 (AAAArecords) for each domain.
- 📝 The updater preserves existing Cloudflare proxy statuses, TTLs, and comments for managed DNS records. You can set fallback values for cases where the updater needs to supply them.
- 📜 The updater can maintain lists of detected IP addresses. These lists can then be referenced in any Cloudflare product that uses Cloudflare’s Rules language, such as Cloudflare Web Application Firewall (WAF) and Cloudflare Rules. (We call the lis 729A ts “WAF lists”, but their use is not limited to Cloudflare WAF.)
- 🩺 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.
The code is extensively tested.
- 🔬 Compatibility with the Cloudflare API is verified periodically with dedicated scripts.
- 🧰 The updater is designed to recover from transient network failures.
-
🙈 By default, public IP addresses are obtained via Cloudflare’s debugging page. This minimizes the impact on privacy because we are already using the Cloudflare API to update DNS records.
-
🛡️ By default, 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 with cosign that the Docker images were built from this repository click to expand
cosign verify favonia/cloudflare-ddns:1 \ --certificate-identity-regexp https://github.com/favonia/cloudflare-ddns/ \ --certificate-oidc-issuer https://token.actions.githubusercontent.com
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 is guided by detailed and principled design documents.
-
📦️ The updater uses only a small set of established external Go packages click to expand
- cloudflare-go: official Go binding of Cloudflare API v4.
- cron: parsing of Cron expressions.
- go-retryablehttp: HTTP clients with retries and exponential backoff.
- go-querystring: library to construct URL query parameters.
- shoutrrr: notification library for sending general updates.
- ttlcache: in-memory cache to hold Cloudflare API responses.
- x/net: official Go supplementary packages for domain handling and low-level DNS support.
- x/text: official Go supplementary packages for locale-aware text handling.
- mock (for testing only): semi-official framework for mocking.
- testify (for testing only): tool set for testing Go programs.
The default Docker image stays small.
- 🗃️ Cloudflare API responses are cached to reduce the API usage.
🐋 Directly run the Docker image click to expand
Create a Cloudflare API token from the API Tokens page with the Zone - DNS - Edit and Account - Account Filter Lists - Edit permissions. You can remove unneeded permissions based on your setup; see Cloudflare API Tokens for details.
docker run \
--network host \
-e CLOUDFLARE_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN \
-e DOMAINS=example.org,www.example.org,example.io \
-e PROXIED=true \
favonia/cloudflare-ddns:1PROXIED=true does not change the proxy statuses of existing records. See DNS and WAF Fallback Values.
🧬 Directly run the updater from its source click to expand
Create a Cloudflare API token from the API Tokens page with the Zone - DNS - Edit and Account - Account Filter Lists - Edit permissions. You can remove unneeded permissions based on your setup; see Cloudflare API Tokens for details.
You need the Go tool to run the updater from its source.
CLOUDFLARE_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@latestPROXIED=true does not change the proxy statuses of existing records. See DNS and WAF Fallback Values.
Incorporate the following fragment into the compose file (typically docker-compose.yml or docker-compose.yaml). The template looks a bit scary only because it includes various optional flags for extra security protection.
services:
cloudflare-ddns:
image: favonia/cloudflare-ddns:1
# Prefer "1" or "1.x.y" in production.
#
# - "1" tracks the latest stable release whose major version is 1
# - "1.x.y" pins one specific stable version
# - "latest" moves to each new stable release and may pick up breaking
# changes in a future major release, so it is not recommended in production
# - "edge" tracks the latest unreleased development build
network_mode: host
# Optional. This bypasses network isolation and makes IPv6 easier.
# See "Use IPv6 without sharing the host network".
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:
- CLOUDFLARE_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
# Leaning toward using Cloudflare's proxy for these domains (optional)
# Existing DNS records in Cloudflare keep their current proxy statusesCLOUDFLARE_API_TOKEN should be a Cloudflare API token, not the older global API key used by some other tools. Create one from the API Tokens page, typically using the Edit zone DNS template. If you also use WAF lists, add the Account - Account Filter Lists - Edit permission.
The user: "1000:1000" line sets the user and group IDs that the container runs as, and you can change those two numbers to match your system. The cap_drop, read_only, and no-new-privileges lines add extra protection, especially when you run the container as a non-superuser.
📍 DOMAINS is the list of domains to update click to expand
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 scenes.
🚨 Remove PROXIED=true if you are not running a web server click to expand
Keep PROXIED=true when you want Cloudflare’s proxy for the domains managed by this updater. Proxying lets Cloudflare cache webpages and hide your IP addresses.
| If you want... | Do this |
|---|---|
| Create new records that expose your real IP addresses | Remove PROXIED=true or change it to PROXIED=false |
| Create new records for non-HTTP(S) traffic | Remove PROXIED=true or change it to PROXIED=false, because Cloudflare cannot proxy it |
| Create new records whose HTTP(S) traffic is proxied | Keep PROXIED=true |
| Change the proxy statuses of existing records | Change them manually on the Cloudflare DNS Records page |
The default value of PROXIED is false.
If you need a non-default Docker Compose deployment, see Docker Compose Special Setups.
docker-compose pull cloudflare-ddns
docker-compose up --detach --build cloudflare-ddnsThe updater should now be running in the background. Check the logs with docker-compose logs cloudflare-ddns and confirm that it started correctly.
These setups are additive changes on top of the basic Docker Compose template in Step 1: Updating the Compose File. Each setup shows a minimal delta. For the exact behavior of each environment variable, see All Settings.
Use this when you want to validate the updater without waiting for a real IP change.
Point the updater at dedicated test domain names and feed it explicit test IPs:
environment:
- DOMAINS=ddns-test.example.org
- IP4_PROVIDER=static:203.0.113.10
- IP6_PROVIDER=static:2001:db8::10After the testing is done, switch DOMAINS, IP4_PROVIDER, and IP6_PROVIDER to your production values.
static:<ip1>,<ip2>,... is an advanced provider that supplies a fixed set of IP addresses. It is useful for tests, debugging, and other setups where you want to feed a known address set into the updater, but it is not the normal long-running DDNS path.
Use this when you want to validate the updater with simulated IP changes by reading test addresses from local files.
Create ip4.txt and ip6.txt with one IP address per line (blank lines and # comments are ignored). Then, point the updater at dedicated test domain names and feed it the file paths:
environment:
- DOMAINS=ddns-test.example.org
- IP4_PROVIDER=file:/ip4.txt
- IP6_PROVIDER=file:/ip6.txt
volumes:
- $PWD/ip4.txt:/ip4.txt
- $PWD/ip6.txt:/ip6.txtAfter the updater creates or updates the expected records, change the addresses in ip4.txt or ip6.txt to simulate further IP changes. The updater should pick up new content and reconcile the DNS records.
After testing is done, switch DOMAINS, IP4_PROVIDER, and IP6_PROVIDER to your production values and remove the test files and volumes: entries.
Use this when you want to test how the updater responds after DNS records are changed directly in Cloudflare.
By default, the updater caches Cloudflare API responses to reduce network traffic. To make it fetch the latest DNS records every time, disable that cache:
environment:
- CACHE_EXPIRATION=1nsWith CACHE_EXPIRATION=1ns, you can edit DNS records in Cloudflare and watch the updater reconcile them right away.
CACHE_EXPIRATION affects cached Cloudflare API responses. It does not affect public IP detection. The updater still detects the current public IP addresses each time it runs.
Restore the default CACHE_EXPIRATION afterward to avoid unnecessary network traffic.
Use this when your network supports only one IP family or when you want to stop seeing detection failures for the other one.
environment:
- IP6_PROVIDER=noneUse IP6_PROVIDER=none to stop managing IPv6, or IP4_PROVIDER=none to stop managing IPv4. Existing managed DNS records of that IP family are preserved. 🧪 If you also use WAF lists, existing managed items of that IP family are preserved there too.
Use this when you want IPv6 support but do not want network_mode: host.
services:
cloudflare-ddns:
# Remove this line:
# network_mode: hostAfter removing network_mode: host, follow the official Docker instructions for enabling IPv6 on your Docker bridge network.
Use this when the updater runs in Docker and should route all outbound requests through a particular host network interface. This is useful when you want Cloudflare-based providers (cloudflare.doh, cloudflare.dot) or other IP detection websites (url:<url>) to see the public IPs via a specific interface.
One possible approach is to use IPvlan network driver to create a virtual network attached to that host interface, and then attach this updater to it. Add the following snippet to your Compose file where <iface> is the name of the host interface:
networks:
ddns:
driver: ipvlan
driver_opts:
parent: <iface>
ipvlan_mode: l2
ipam:
config:
- subnet: <subnet>
gateway: <gateway>Replace <iface>, <subnet>, and <gateway> to match the host interface you want the container to use.
- On the host, run
ip route show dev <iface> proto kerneland use the subnet in CIDR notation as your<subnet>, such as192.168.1.0/24. - On the host, run
ip route show default dev <iface>and use theviaaddress as your<gateway>, such as192.168.1.1.
Then attach the service to that network instead of using network_mode: host:
services:
cloudflare-ddns:
# This line replaces "network_mode: host"
networks: [ddns]ipvlan does not use Docker’s usual bridge-network firewall rules, and the host is not directly reachable from the container on this network. Make sure your host firewall expects the new traffic.
Use this when you are already using network_mode: host and want the updater to read addresses from one specific host interface instead of choosing from all host interfaces.
environment:
- IP4_PROVIDER=local.iface:eth0
- IP6_PROVIDER=local.iface:eth0If you want to change where outbound requests leave the container instead, see Route through a specific host interface.
🧪 local.iface:<iface> is still experimental.
Use this when you do not want to put the token directly in the Compose file or .env file.
Replace the inline token with a file-backed token:
services:
cloudflare-ddns:
environment:
- CLOUDFLARE_API_TOKEN_FILE=/run/secrets/cloudflare_api_token
secrets:
- cloudflare_api_token
secrets:
cloudflare_api_token:
file: ./secrets/cloudflare_api_token.txtuser: "UID:GID".
The updater can work without DNS records and manage only WAF lists.
environment:
- WAF_LISTS=0123456789abcdef0123456789abcdef/home-ips
# Do not set DOMAINS, IP4_DOMAINS, or IP6_DOMAINSUse a Cloudflare API token with the Account - Account Filter Lists - Edit permission.
Use this when multiple updater instances may overlap and each instance should manage only its own resources. The DNS setup and the WAF setup are independent:
- If multiple instances share DNS domains, configure the DNS subsection.
- If multiple instances share WAF lists, configure the WAF subsection.
- If both apply, configure both subsections.
Use this when multiple instances share DNS domains. This setup does not use WAF lists. Give each instance its own DNS record comment value and matching selector:
- Set a unique
RECORD_COMMENT. - Set
MANAGED_RECORDS_COMMENT_REGEXto match that same DNS comment, typically with^...$.
Example:
- Instance A:
RECORD_COMMENT=managed-by-ddns-a,MANAGED_RECORDS_COMMENT_REGEX=^managed-by-ddns-a$ - Instance B:
RECORD_COMMENT=managed-by-ddns-b,MANAGED_RECORDS_COMMENT_REGEX=^managed-by-ddns-b$
This setup requires
MANAGED_RECORDS_COMMENT_REGEX(available since version 1.16.0).
Use this when multiple instances share WAF lists. This setup does not use the DNS settings in Share DNS domains across updater instances. Give each instance its own WAF list item comment and matching selector:
- Set a unique
WAF_LIST_ITEM_COMMENT. - Set
MANAGED_WAF_LIST_ITEMS_COMMENT_REGEXto match that same WAF list item comment, typically with^...$.
Example:
- Instance A:
WAF_LIST_ITEM_COMMENT=managed-by-ddns-a,MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX=^managed-by-ddns-a$ - Instance B:
WAF_LIST_ITEM_COMMENT=managed-by-ddns-b,MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX=^managed-by-ddns-b$
🧪 This setup requires
WAF_LIST_ITEM_COMMENT(available since version 1.16.0) andMANAGED_WAF_LIST_ITEMS_COMMENT_REGEX(available since version 1.16.0). Both settings are experimental.
These setups are for runtimes that are not additive changes on top of the Docker Compose template in Step 1: Updating the Compose File.
The repository currently includes community-contributed sample configurations for OpenBSD. Additional service-manager examples, such as systemd, belong there too.
Start with the same image and environment variables shown in Quick Start or Step 1: Updating the Compose File, then adapt the run command to your Podman workflow. This README does not currently maintain Podman-specific commands, Quadlet files, or Compose conversions.
Due to high maintenance costs, the dedicated Kubernetes instructions have been removed. You can still generate Kubernetes configurations from the Docker Compose template using Kompose version 1.35.0 or later. A simple Deployment is sufficient here; there is no inbound traffic, so a Service is not required. This README does not maintain first-party Kubernetes manifests.
Some Docker, kernel, and virtualization combinations do not work well with security_opt: [no-new-privileges:true]. If this happens, try removing that one hardening option and start the container again. This slightly reduces security, so keep the other hardening options if possible.
If removing no-new-privileges fixes the problem, keep it disabled for this container or adjust your security policy to allow this binary.
If removing no-new-privileges does not help, try a minimal image such as alpine or another popular Docker image with the same hardening option. If that also fails, the problem is likely in the host environment rather than this updater. Reported cases have included older kernels and some QEMU/Proxmox-style virtualized setups.
If none of these applies, please open an issue on GitHub and include your compose file with secrets redacted, docker version, uname -a, your host OS and virtualization platform (if any), and whether a minimal image such as alpine shows the same error.
There have been reports of intermittent issues with the default provider cloudflare.trace. If you see error code: 1034, upgrade to version 1.15.1 or later, or switch to another provider such as cloudflare.doh or url:<url>.
The first thing to check is whether a container can reach Cloudflare from the Docker environment at all. A simple way to test that is to run a minimal image such as alpine and try both DNS resolution and HTTPS connectivity:
docker run --rm alpine nslookup api.cloudflare.com
docker run --rm alpine wget -qO- https://api.cloudflare.com/cdn-cgi/traceIf nslookup fails, your Docker setup likely has a DNS problem. If wget fails, outbound HTTPS connectivity to Cloudflare is likely blocked or broken. If both commands work, try increasing DETECTION_TIMEOUT (for example, DETECTION_TIMEOUT=1m) in case requests are simply slow in your environment.
If that still does not help, please open a GitHub issue and include your setup details, relevant configs with se 7292 crets redacted, and any logs you have so that we can investigate further.
If your router shows an address between 100.64.0.0 and 100.127.255.255, you are likely behind CGNAT (Carrier-grade NAT). In that case, your ISP is not giving you a real public IP address, so ordinary DDNS cannot make your home network directly reachable from the Internet.
Your options are usually to switch to an ISP that gives you a real public IP address or to use a different approach such as Cloudflare Tunnel.
The updater does not add timestamps itself because most runtimes already do:
- If you are using Docker Compose, Kubernetes, or Docker directly, add
--timestampswhen viewing the logs. - If you are using Portainer, enable “Show timestamp” when viewing the logs.
The emoji “🧪” marks experimental features, and the emoji “🤖” marks technical details that most readers can skip on a first pass.
🔐 Cloudflare API Access click to expand
Starting with version 1.15.0, the updater supports environment variables that begin with
CLOUDFLARE_*. Multiple environment variables can be used at the same time, provided they all specify the same token.
| Name | Meaning |
|---|---|
CLOUDFLARE_API_TOKEN |
The Cloudflare API token to access the Cloudflare API |
CLOUDFLARE_API_TOKEN_FILE |
An absolute path to a file that contains the Cloudflare API token to access the Cloudflare API |
CF_API_TOKEN (will be deprecated in version 2.0.0) |
Same as CLOUDFLARE_API_TOKEN |
CF_API_TOKEN_FILE (will be deprecated in version 2.0.0) |
Same as CLOUDFLARE_API_TOKEN_FILE |
🚂 Cloudflare is updating its tools to use environment variables starting with
CLOUDFLARE_*instead ofCF_*. It is recommended to align your setting with this new convention. However, the updater will fully support bothCLOUDFLARE_*andCF_*environment variables until version 2.0.0.🌐 To update DNS records, the updater needs the Zone - DNS - Edit permission.
📋️ To manipulate WAF lists, the updater needs the Account - Account Filter Lists - Edit permission.
💡
CLOUDFLARE_API_TOKEN_FILEworks well with Docker secrets where secrets will be mounted as files at/run/secrets/<secret-name>.
⚠️ Any*_FILEvariable must point to a file readable by the user configured byuser: "UID:GID".
🌐 DNS Record Scope click to expand
You need to specify at least one thing in
DOMAINS,IP4_DOMAINS, orIP6_DOMAINSfor the updater to manage DNS records.
| Name | Meaning | Default Value |
|---|---|---|
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. |
"" (empty list) |
IP4_DOMAINS |
Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for A records (in addition to those in DOMAINS) |
"" (empty list) |
IP6_DOMAINS |
Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for AAAA records (in addition to those in DOMAINS) |
"" (empty list) |
MANAGED_RECORDS_COMMENT_REGEX (available since version 1.16.0) |
Regex that selects which DNS records this updater manages by their comments. Matched records are updated or deleted as needed; new records are created with comments that match. Uses RE2 syntax (the Go regexp syntax, not Perl/PCRE). |
"" (empty regex; manages all DNS records) |
🔗
DOMAINS,IP4_DOMAINS, andIP6_DOMAINSare additive; they do not override each other. For example, settingDOMAINS=a.organdIP4_DOMAINS=b.orgmeans the updater managesArecords for botha.organdb.org(andAAAArecords fora.org).🤖 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.orgwould not work.🤖 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.
📋️ WAF List Scope click to expand
The updater can maintain WAF lists to match detected IP addresses. By default, IPv4 addresses are stored individually and IPv6 addresses are stored as
/64ranges.
| Name | Meaning | Default Value |
|---|---|---|
🧪 WAF_LISTS (available since version 1.14.0) |
🧪 Comma-separated references of WAF lists the updater should manage. A list reference is written in the format 🔑 The API token needs the Account - Account Filter Lists - Edit permission. |
"" (empty list) |
🧪 MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX (available since version 1.16.0) |
🧪 Regex that selects which WAF list items this updater manages by their comments. This lets multiple updater instances share one WAF list safely: matched items are updated or deleted as needed, and new items are created with comments that match. Uses RE2 syntax (the Go regexp syntax, not Perl/PCRE). |
"" (empty regex; manages all WAF list items) |
🧪 The defaults (individual IPv4 addresses, i.e.
/32;/64ranges for IPv6) are configurable viaIP4_DEFAULT_PREFIX_LENandIP6_DEFAULT_PREFIX_LENin the IP Detection section. If a detected address already carries its own prefix length (from CIDR notation), that prefix length is used instead of the default.🤖 Existing ranges in the list that already cover a detected address are kept as-is. See IPv6 Default Prefix Length Policy for the design rationale behind the
/64default.
🔍️ IP Detection click to expand
| Name | Meaning | Default Value |
|---|---|---|
IP4_PROVIDER |
This specifies how to detect the current IPv4 address. Available providers include cloudflare.trace, cloudflare.doh, local, local.iface:<iface>, url:<url>, url.via4:<url>, url.via6:<url>, static:<ip1>,<ip2>,..., static.empty, file:<absolute-path>, and none. The special none provider stops managing IPv4. See the provider table in this section for the detailed explanation. |
cloudflare.trace |
IP6_PROVIDER |
This specifies how to detect the current IPv6 address. Available providers include cloudflare.trace, cloudflare.doh, local, local.iface:<iface>, url:<url>, url.via4:<url>, url.via6:<url>, static:<ip1>,<ip2>,..., static.empty, file:<absolute-path>, and none. The special none provider stops managing IPv6. See the provider table in this section for the detailed explanation. |
cloudflare.trace |
🧪 IP4_DEFAULT_PREFIX_LEN (available since version 1.16.0) |
🧪 The default CIDR prefix length for detected bare IPv4 addresses. When a provider discovers a bare address (without CIDR notation), this prefix length is attached. DNS records currently ignore this setting, but future features may use it. WAF lists use the prefix length to determine the stored range: for example, 24 stores each bare detection as a /24 range. Valid range: 8–32. |
32 |
🧪 IP6_DEFAULT_PREFIX_LEN (available since version 1.16.0) |
🧪 The default CIDR prefix length for detected bare IPv6 addresses. When a provider discovers a bare address (without CIDR notation), this prefix length is attached. DNS records currently ignore this setting, but future features may use it. WAF lists use the prefix length to determine the stored range: for example, 48 stores each bare detection as a /48 range. Valid range: 12–128. 🤖 See IPv6 Default Prefix Length Policy for the design rationale behind the /64 default (instead of /128). |
64 |
👉️ The option
IP4_PROVIDERgovernsA-type DNS records and IPv4 addresses in WAF lists, while the optionIP6_PROVIDERgovernsAAAA-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.
| Provider Name | Explanation |
|---|---|
cloudflare.trace |
Get the IP address by parsing the Cloudflare debugging page. This is the default provider. |
cloudflare.doh |
Get the IP address by querying whoami.cloudflare. against Cloudflare via DNS-over-HTTPS. |
local |
Get the IP address via local network interfaces and routing tables. The updater will use the local address that would have been used for outbound UDP connections to Cloudflare servers. (No data will be transmitted.)
|
🧪 local.iface:<iface> (available since version 1.15.0) |
🧪 Get IP addresses via the specific local network interface
🤖 The updater ignores the prefix length reported by the interface, because it commonly describes its local subnet, not the range the updater should claim. The updater uses the default prefix lengths from |
url:<url> |
Fetch the IP address from a URL. The provider format is The updater connects over IPv4 for 🧪 The response may also contain multiple addresses or addresses in CIDR notation, using the line-based text format described after this table. 🕰️ Before version 1.15.0, |
url.via4:<url> (available since version 1.16.0) |
Fetch the IP address from a URL while always connecting to that URL over IPv4. 🧪 Same text format as The intention is to get an IPv6 address over IPv4 with |
url.via6:<url> (available since version 1.16.0) |
Fetch the IP address from a URL while always connecting to that URL over IPv6. 🧪 Same text format as The intention is to get an IPv4 address over IPv6 with |
file:<absolute-path> (available since version 1.16.0) |
Read the IP address from a local file. The path must be absolute. The file is re-read on every detection cycle, so you can update it without restarting the updater. 🧪 The file may also use the line-based text format described after this table for multiple addresses.
|
static:<ip1>,<ip2>,... (available since version 1.16.0) |
Use one or more explicit IP addresses (or 🧪 addresses in CIDR notation) as a fixed set, separated by commas. This is an advanced provider for tests, debugging, and special fixed-input setups. 🤖 The entries are parsed, deduplicated, sorted, and validated for the selected IP family via the same normalization pipeline used by other providers. |
static.empty (available since version 1.16.0) |
Clear existing managed content for the selected IP family. In contrast, 🧪 If you also use WAF lists, this clears managed items of that IP family but does not delete the list itself. The updater will try to delete the list on exit only when |
none |
Stop managing the specified IP family for this run. For example 🧪 Existing managed WAF list items of that IP family are preserved too, because that family is out of scope. Use |
🧪 The
url,url.via4,url.via6, andfileproviders share the following line-based text format. Each line is one IP address or an address in CIDR notation (e.g.,198.51.100.1/24). Blank lines are ignored and#starts a comment. All entries must belong to the selected IP family; mismatched entries are rejected. Entries are deduplicated and sorted. There must be at least one entry.# Bare addresses 198.51.100.1 198.51.100.2 # Addresses in CIDR notation (experimental) 198.51.100.0/24 198.51.100.128/25 # inline comments are supported
📅 Update Schedule and Lifecycle click to expand
| 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 managed WAF content are deleted when the updater exits. It accepts any boolean value supported by strconv.ParseBool, such as DNS cleanup applies only to the IP families this updater is managing in that run. 🧪 For WAF lists, the updater deletes the whole list only when the updater manages both IP families and no filtering is enabled by |
false |
TZ |
The timezone used for logging messages and parsing 🤖 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 🤖 The update schedule does not take the time to update records into consideration. For example, if the schedule is |
@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 |
💡 Active cleanup tip: set one or both IP providers to
static.emptyand useUPDATE_CRON=@onceto remove managed DNS records or managed WAF items and then exit. If both providers arestatic.empty, you can addDELETE_ON_STOP=trueto make the updater try to delete the WAF list itself too.
⏳️ Operation Timeouts click to expand
| 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
992E
update DNS records, per domain and per record type, or per WAF list. It can be any positive time duration accepted by time.ParseDuration, such as 1h or 10m. |
30s (30 seconds) |
🛟 DNS and WAF Fallback Values click to expand
The updater preserves existing attributes (such as TTL and proxy status) when possible. 🤖 It keeps existing attribute values when old content agrees on them; otherwise, it uses the fallback values in this table when the values conflict.
| Name | Meaning | Default Value |
|---|---|---|
PROXIED |
Fallback proxy setting for DNS records managed by the updater. It can be any boolean value accepted by strconv.ParseBool, such as 🤖 Advanced usage: it can also be a domain-dependent boolean expression, as described in the examples later in this section. |
false |
TTL |
Fallback TTL (in seconds) for DNS records managed by the updater. | 1 (This means “automatic” to Cloudflare) |
RECORD_COMMENT |
Fallback record comment for DNS records managed by the updater. | "" |
🧪 WAF_LIST_DESCRIPTION (available since version 1.14.0) |
🧪 Fallback description for WAF lists managed by the updater. 🤖 This matters only when the updater needs to create a new WAF list, because a WAF list has only one description. |
"" |
🧪 WAF_LIST_ITEM_COMMENT (available since version 1.16.0) |
🧪 Fallback comment for WAF list items managed by the updater. | "" |
🤖 For DNS records, the updater recycles existing records when it can (instead of delete-then-create). Cloudflare does not support updating one WAF list item in place, so WAF changes always use delete-then-create.
🤖 For advanced users:
PROXIEDcan also be a domain-dependent boolean expression. This lets you enable Cloudflare proxying for some managed domains but not others. Here are some example expressions:
PROXIED=is(example.org): proxy only the domainexample.orgPROXIED=is(example1.org) || sub(example2.org): proxy only the domainexample1.organd subdomains ofexample2.orgPROXIED=!is(example.org): proxy every managed domain except forexample.orgPROXIED=is(example1.org) || is(example2.org) || is(example3.org): proxy only the domainsexample1.org,example2.org, andexample3.orgA boolean expression can take one of the following forms (all whitespace is ignored):
Syntax Meaning Any string accepted by strconv.ParseBool, such as true,false,0, or1Logical truth or falsehood is(d)Matching the domain d. Note thatis(*.a)only matches the wildcard domain*.a; usesub(a)to match all subdomains ofa(including*.a).sub(d)Matching subdomains of d, includinga.d,b.c.d, and wildcard domains like*.dand*.a.d, but notditself.! eLogical negation of the boolean expression ee1 || e2Logical disjunction of the boolean expressions e1ande2e1 && e2Logical conjunction of the boolean expressions e1ande2One can use parentheses to group expressions, such as
!(is(a) && (is(b) || is(c))). For convenience, the parser also accepts these short forms:
Short Form Equivalent Full Form is(d1, d2, ..., dn)is(d1) || is(d2) || ... || is(dn)sub(d1, d2, ..., dn)sub(d1) || sub(d2) || ... || sub(dn)For example, these two settings are equivalent:
PROXIED=is(example1.org) || is(example2.org) || is(example3.org)PROXIED=is(example1.org,example2.org,example3.org)
👁️ Logging click to expand
| 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 |
📣 Notifications click to expand
💡 If your network doesn’t support IPv6, set
IP6_PROVIDER=noneto stop managing IPv6. This will prevent the updater from reporting failures in detecting IPv6 addresses to monitoring services. Similarly, setIP4_PROVIDER=noneif your network doesn’t support IPv4.
| Name | Meaning |
|---|---|
HEALTHCHECKS |
The Healthchecks ping URL to ping when the updater successfully updates IP addresses, such as
|
UPTIMEKUMA |
The Uptime Kuma’s Push URL to ping when the updater successfully updates IP addresses, such as
|
SHOUTRRR |
Newline-separated shoutrrr URLs to which the updater sends notifications of IP address changes and other events. In other words, put one URL on each line. Each shoutrrr URL represents a notification service; for example, If you configure this value via YAML, prefer literal block style |
If you are using Docker Compose, run docker-compose up --detach to reload settings.
I am migrating from oznu/cloudflare-ddns (now archived) click to expand
| Old Parameter | Note | |
|---|---|---|
API_KEY=<key> |
Legacy global API keys are not supported. Please generate a scoped API token and use CLOUDFLARE_API_TOKEN=<token>. |
|
API_KEY_FILE=/path/to/key-file |
Legacy global API keys are not supported. Please generate a scoped API token, save it, and use CLOUDFLARE_API_TOKEN_FILE=/path/to/token-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 stop managing IPv6 |
RRTYPE=AAAA |
✔️ | Both IPv4 and IPv6 are enabled by default; use IP4_PROVIDER=none to stop managing IPv4 |
DELETE_ON_STOP=true |
✔️ | Same (DELETE_ON_STOP=true) |
INTERFACE=<iface> |
✔️ | To automatically select the local address, use IP4/6_PROVIDER=local. 🧪 To select addresses of a specific network interface, use IP4/6_PROVIDER=local.iface:<iface> (available since version 1.15.0). Since version 1.16.0, the updater collects all matching global unicast addresses instead of just the first one, then reconciles DNS records and WAF lists against that full detected set. |
CUSTOM_LOOKUP_CMD=cmd |
❌️ | Custom commands are not supported because there are no other programs in the minimal Docker image |
DNS_SERVER=server |
❌️ | For DNS-based IP detection, the updater only supports secure DNS queries using Cloudflare’s DNS over HTTPS (DoH) server. To enable this, set IP4/6_PROVIDER=cloudflare.doh. To detect IP addresses via HTTPS by querying other servers, use IP4/6_PROVIDER=url:<url> |
I am migrating from timothymiller/cloudflare-ddns click to expand
Since timothymiller/cloudflare-ddns 2.0.0, many setting names and features look very close to this updater. However, similar names do not necessarily mean identical semantics. Most mismatches will produce a clear startup error, but some differences are silent. If you only set a few options, checking the documentation for those specific settings should be quick and worthwhile. Here are two notable differences:
-
⚠️ sub()inPROXIEDexpressions: In this updater,sub(example.com)matches strict subdomains only—it does not matchexample.comitself. In timothymiller/cloudflare-ddns,sub(example.com)matchesexample.comand all its subdomains. Copying aPROXIEDexpression verbatim may silently change which domains are proxied. If you need to match a domain and all its subdomains, useis(example.com) || sub(example.com). -
literal:(timothymiller/cloudflare-ddns) is calledstatic:in this updater (e.g.,IP4_PROVIDER=static:1.2.3.4).
📜 Some historical notes: This updater was originally written as a Go clone of the Python program timothymiller/cloudflare-ddns because the Python program purged unmanaged DNS records back then and it was not configurable via environment variables on its default branch. Eventually, the Python program became configurable via environment variables and later was rewritten in Rust, but this Go updater had already gone its own way. My opinions are biased, so please check the technical details by yourself. 😉
Questions, suggestions, feature requests, and contributions are all welcome! Feel free to open a GitHub issue.
The code is licensed under Apache 2.0 with LLVM exceptions. (The LLVM exceptions provide better compatibility with GPL 2.0 and other license exceptions.)