# 🌟 Cloudflare DDNS
[](https://github.com/favonia/cloudflare-ddns)
[](https://app.codecov.io/gh/favonia/cloudflare-ddns)
[](https://hub.docker.com/r/favonia/cloudflare-ddns)
[](https://www.bestpractices.dev/en/projects/6680/passing)
[](https://securityscorecards.dev/viewer/?uri=github.com/favonia/cloudflare-ddns)
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.
The emoji “🧪” marks experimental features, and the emoji “🤖” marks technical details that most readers can skip on a first pass.
## ✨ Highlights
### ✅ Comprehensive Support of Domain Names
- 😌 You can simply list domains (_e.g._, `www.a.org, hello.io`) without knowing their DNS zones.
- 🌍 [Internationalized domain names](https://en.wikipedia.org/wiki/Internationalized_domain_name) (_e.g._, `🐱.example.org` and `日本。co。jp`) are fully supported.
- 🃏 [Wildcard domains](https://en.wikipedia.org/wiki/Wildcard_DNS_record) (_e.g._, `*.example.org`) are also supported.
- 🕹️ You can toggle IPv4 (`A` records) and IPv6 (`AAAA` records) for each domain.
### 🌥️ Cloudflare-specific Features
- 📝 The updater preserves existing [Cloudflare proxy statuses](https://developers.cloudflare.com/dns/proxy-status/), [TTLs](https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/), and [comments](https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/) for managed DNS records. You can set fallback values for cases where the updater needs to supply them.
- 📜 The updater can maintain [lists](https://developers.cloudflare.com/waf/tools/lists/custom-lists/) of detected IP addresses. These lists can then be referenced in any Cloudflare product that uses [Cloudflare’s Rules language](https://developers.cloudflare.com/ruleset-engine/), such as [Cloudflare Web Application Firewall (WAF)](https://developers.cloudflare.com/waf/) and [Cloudflare Rules](https://developers.cloudflare.com/rules/). (We call the lists “WAF lists”, but their use is not limited to Cloudflare WAF.)
### 🔔 Integration with Notification Services
- 🩺 The updater can report to [Healthchecks](https://healthchecks.io) or [Uptime Kuma](https://uptime.kuma.pet) 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](https://containrrr.dev/shoutrrr/), including emails, major notification services, major messaging platforms, and generic webhooks.
### 🧱 Reliability
- The code is extensively tested.
- 🔬 Compatibility with the Cloudflare API is verified periodically with [dedicated scripts](scripts/README.markdown).
- 🧰 The updater is designed to recover from transient network failures.
### 🔒 Security and Privacy
- 🙈 By default, public IP detection stays with Cloudflare: the `cloudflare.trace` provider contacts [HTTPS trace endpoints operated by Cloudflare](https://developers.cloudflare.com/fundamentals/reference/cdn-cgi-endpoint/), and the updater already contacts Cloudflare to update DNS records. See [IP Detection](#ip-detection) for the current endpoint list.
- 🛡️ By default, the updater uses only HTTPS or [DNS over HTTPS](https://en.wikipedia.org/wiki/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](docs/design/features/network-security-model.markdown) for more information.
-
🔏 You can verify with cosign that the Docker images were built from this repository click to expand
```bash
cosign verify favonia/cloudflare-ddns:1 \
--certificate-identity-regexp https://github.com/favonia/cloudflare-ddns/ \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```
The GHCR mirror (available since version 1.17.1) can be verified with the same command by replacing the image reference `favonia/cloudflare-ddns:1` with `ghcr.io/favonia/cloudflare-ddns:1`.
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 a small set of established external Go packages click to expand
The default Docker image stays small.
- 🗃️ Cloudflare API responses are cached to reduce the API usage.
## 🚀 Quick Start
🐋 Directly run the Docker image click to expand
Create a Cloudflare API token from the [API Tokens page](https://dash.cloudflare.com/profile/api-tokens) 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](#cloudflare-api-token) for details.
```bash
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:1
```
The GHCR mirror at `ghcr.io/favonia/cloudflare-ddns` (available since version 1.17.1) uses the same image tags as `favonia/cloudflare-ddns`.
⚠️ `PROXIED=true` does not change the proxy statuses of existing records. See [DNS and WAF Fallback Values](#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](https://dash.cloudflare.com/profile/api-tokens) 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](#cloudflare-api-token) for details.
You need the [Go tool](https://go.dev/doc/install) to run the updater from its source.
```bash
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@latest
```
⚠️ `PROXIED=true` does not change the proxy statuses of existing records. See [DNS and WAF Fallback Values](#dns-and-waf-fallback-values).
CLOUDFLARE_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.
DOMAINS is the list of domains to update click to expandPROXIED=true if you are not running a web server click to expandexec /bin/ddns: operation not permitted
Some Docker, kernel, and virtualization combinations do not work well with [`security_opt: [no-new-privileges:true]`](https://docs.docker.com/reference/cli/docker/container/run/). 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](https://github.com/favonia/cloudflare-ddns/issues/new/choose) 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.
### 🤔 I am getting error code: 1034
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:context deadline exceeded and IP detection failed
First check the updater logs to identify the provider and IP family that timed out. If the failed provider is `cloudflare.trace`, test every current built-in endpoint from a diagnostic container that shares the updater container’s existing network stack. The commands below use the `cloudflare-ddns` service name from this README and test IPv4; replace the service name if yours differs, and replace `--ipv4` with `--ipv6` for an IPv6 failure. If the updater receives explicit proxy environment variables, pass the same variables to the diagnostic container.
```bash
DDNS_CONTAINER="$(docker-compose ps -q cloudflare-ddns)"
docker run --rm --network "container:${DDNS_CONTAINER}" curlimages/curl:latest --fail --show-error --silent --ipv4 https://api.cloudflare.com/cdn-cgi/trace
docker run --rm --network "container:${DDNS_CONTAINER}" curlimages/curl:latest --fail --show-error --silent --ipv4 https://www.cloudflare.com/cdn-cgi/trace
docker run --rm --network "container:${DDNS_CONTAINER}" curlimages/curl:latest --fail --show-error --silent --ipv4 https://connectivity.cloudflareclient.com/cdn-cgi/trace
```
If `curl` reports `Could not resolve host`, DNS resolution failed in the updater’s container network. Check the host’s DNS configuration and Docker’s [DNS services](https://docs.docker.com/engine/network/#dns-services). A failure at one endpoint does not by itself prevent detection, because another endpoint may return a valid response. If any of the three commands succeeds but detection still times out, check the updater logs for response-validation failures, then try increasing `DETECTION_TIMEOUT` (for example, `DETECTION_TIMEOUT=1m`).
If that still does not help, please [open a GitHub issue](https://github.com/favonia/cloudflare-ddns/issues/new/choose) and include your setup details, relevant configs with secrets redacted, and any logs you have so that we can investigate further.
### 🤔 Why did the updater detect a public IP address different from the WAN IP address on my router?
If your router shows an address between `100.64.0.0` and `100.127.255.255`, you are likely behind [CGNAT (Carrier-grade NAT)](https://en.wikipedia.org/wiki/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](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/).
### 🤔 How can I see the timestamps of the IP checks and/or updates?
The updater does not add timestamps itself because most runtimes already do:
- If you are using Docker Compose, Kubernetes, or Docker directly, add `--timestamps` when viewing the logs.
- If you are using Portainer, [enable “Show timestamp” when viewing the logs](https://docs.portainer.io/user/docker/containers/logs).
## 🎛️ Further Customization
### ⚙️ All Settings
🧪 Comma-separated references of [WAF lists](https://developers.cloudflare.com/waf/tools/lists/custom-lists/) the updater should manage. A list reference is written in the format `
🔑 The API token needs the **Account - Account Filter Lists - Edit** permission.
💡 See [how to find your account ID](https://developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/).
e1 \|\| e2 | Logical disjunction of the boolean expressions `e1` and `e2` |
> | `e1 && e2` | Logical conjunction of the boolean expressions `e1` and `e2` |
>
> One can use parentheses to group expressions, such as `!(addr-in(10.0.0.0/8) || addr-in(192.168.0.0/16))`.
| Provider Name | Explanation |
| -- | -- |
| `cloudflare.trace` | Get the IP address from Cloudflare’s HTTPS trace endpoints. It may contact the following hosts and uses the first validated response:
**This is the default provider.**
| | `cloudflare.doh` | Get the IP address by querying `whoami.cloudflare.` against [Cloudflare via DNS-over-HTTPS](https://developers.cloudflare.com/1.1.1.1/encryption/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.)
⚠️ The updater needs access to the host network (such as `network_mode: host` in Docker Compose) for this provider, for otherwise the updater will detect the addresses inside [the default bridge network in Docker](https://docs.docker.com/engine/network/drivers/bridge/) instead of those in the host network.
| | 🧪 `local.iface:🧪 Get IP addresses via the specific local network interface `iface`. Since version 1.16.0, the updater collects all matching global unicast addresses of the selected IP family (IPv4 or IPv6) instead of just the first one, then reconciles DNS records and WAF lists against that full set.
⚠️ The updater needs access to the host network (such as `network_mode: host` in Docker Compose) for this provider, for otherwise the updater cannot access host network interfaces.
🤖 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 `IP4_DEFAULT_PREFIX_LEN` or `IP6_DEFAULT_PREFIX_LEN` instead.
| | `url:Fetch the IP address from a URL. The provider format is `url:` followed by the URL itself. For example, `IP4_PROVIDER=url:https://api4.ipify.org` fetches the IPv4 address from
The updater connects over IPv4 for `IP4_PROVIDER` and over IPv6 for `IP6_PROVIDER`. The intention is to query a public IP detection server with the correct IP family. If you want to override that, use `IP4_PROVIDER=url.via6:
The response may also use CIDR notation. 🧪 It may contain multiple addresses using the line-based text format described after this table.
🕰️ Before version 1.15.0, `url:
Fetch the IP address from a URL while always connecting to that URL over IPv4. It accepts the same CIDR notation and 🧪 multiple-address text format as `url:`.
The intention is to get an IPv6 address over IPv4 with `IP6_PROVIDER=url.via4:
Fetch the IP address from a URL while always connecting to that URL over IPv6. It accepts the same CIDR notation and 🧪 multiple-address text format as `url:`.
The intention is to get an IPv4 address over IPv6 with `IP4_PROVIDER=url.via6:
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.
⚠️ The file must be readable by the user configured by `user: "UID:GID"`.
| | `static: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.
⚠️ Most users should not use it for normal long-running DDNS.
🤖 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, `none` preserves existing content for that family.
🧪 If you also use WAF lists, this clears managed items of that IP family but does not delete the list itself. For deleting WAF lists themselves, see [`DELETE_ON_STOP`](#delete-on-stop).
⚠️ Most users should not use it for normal long-running DDNS.
| | `none` |Stop managing the specified IP family for this run. For example `IP4_PROVIDER=none` stops managing IPv4. Existing DNS records of that IP family are preserved.
🧪 Existing WAF list items of that IP family are preserved too, because that family is out of scope. Use `static.empty` if you want to clear managed content for that family. As the support of WAF lists is still experimental, please [provide feedback](https://github.com/favonia/cloudflare-ddns/issues/new/choose) if this does not match your needs.
| > 🧪 The `url`, `url.via4`, `url.via6`, and `file` providers can use the following line-based text format for multiple addresses. 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. > > ```txt > # Bare addresses > 198.51.100.1 > 198.51.100.2 > > # Addresses in CIDR notation > 198.51.100.0/24 > 198.51.100.128/25 # inline comments are supported > ```Whether managed DNS records and managed WAF content are deleted when the updater exits. It accepts any boolean value supported by [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool), such as `true`, `false`, `0`, or `1`.
DNS cleanup applies to the domains and IP families this updater manages. It deletes only records whose comments match [`MANAGED_RECORDS_COMMENT_REGEX`](#managed-records-comment-regex).
🧪 For WAF lists, the updater tries to delete the whole list only when the updater manages both IP families and no filtering is enabled by `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX`. Otherwise shutdown cleanup keeps the list and deletes only managed items in the managed IP families.
| `false` | | `TZ` |The timezone used for logging messages and parsing `UPDATE_CRON`. It can be any timezone accepted by [time.LoadLocation](https://pkg.go.dev/time#LoadLocation), including any IANA Time Zone.
🤖 The pre-built Docker images come with the embedded timezone database via the [time/tzdata](https://pkg.go.dev/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](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format) or the special value `@once`. The special value `@once` runs one update or cleanup, then exits without scheduling further updates.
With `@once`, enabling `DELETE_ON_STOP` runs cleanup directly. This combination requires `static.empty` for each managed IP family; use `none` for an IP family you want to leave unchanged.
🤖 With `@every 5m`, if a check and its updates take two minutes, the updater only waits about three minutes before starting the next check.
| `@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](https://pkg.go.dev/strconv#ParseBool), such as `true`, `false`, `0`, or `1`. With `UPDATE_CRON=@once`, this must be `true`. | `true` | See [Exit Status](#exit-status) for how the updater reports success or failure. > 💡 Active cleanup tip: to clear both IPv4 and IPv6 content, set both IP providers to `static.empty`. To clear one family’s content and preserve the other’s, use `static.empty` for the family to clear and `none` for the family to preserve. Use `UPDATE_CRON=@once` to remove the selected families’ managed DNS records and WAF items, then exit. For deleting WAF lists themselves, see [`DELETE_ON_STOP`](#delete-on-stop).Fallback proxy setting for DNS records managed by the updater. It can be any boolean value accepted by [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool), such as `true`, `false`, `0`, or `1`.
🤖 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](https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/) 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: `PROXIED` can also be a domain-dependent boolean expression, allowing different fallback proxy values for different domains. For example: > > - `PROXIED=is(example.org)`: `true` for `example.org`, `false` otherwise > - `PROXIED=is(example1.org) || sub(example2.org)`: `true` for `example1.org` and subdomains of `example2.org`, `false` otherwise > - `PROXIED=!is(example.org)`: `false` for `example.org`, `true` otherwise > - `PROXIED=is(example1.org) || is(example2.org) || is(example3.org)`: `true` for `example1.org`, `example2.org`, and `example3.org`, `false` otherwise > > A boolean expression can take one of the following forms (all whitespace is ignored): > > | Syntax | Meaning | > | -- | -- | > | Any string accepted by [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool), such as `true`, `false`, `0`, or `1` | Logical truth or falsehood | > | `is(d)` | Matching the domain `d`. Note that `is(*.example.org)` only matches the wildcard domain `*.example.org`; use `sub(example.org)` to match all subdomains of `example.org` (including `*.example.org`). | > | `sub(d)` | Matching subdomains of `d`, including `a.example.org`, `b.c.example.org`, and wildcard domains like `*.example.org` and `*.a.example.org`, but not `example.org` itself. | > | `! e` | Logical negation of the boolean expression `e` | > |e1 \|\| e2 | Logical disjunction of the boolean expressions `e1` and `e2` |
> | `e1 && e2` | Logical conjunction of the boolean expressions `e1` and `e2` |
>
> One can use parentheses to group expressions, such as `!(is(example1.org) && (is(example2.org) || is(example3.org)))`. 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)`
The [Healthchecks ping URL](https://healthchecks.io/docs/) to ping when the updater successfully updates IP addresses, such as `https://hc-ping.com/
⚠️ The ping schedule should match the update schedule specified by `UPDATE_CRON`.
🤖 The updater can work with _any_ server following the [same Healthchecks protocol](https://healthchecks.io/docs/http_api/), including self-hosted instances of [Healthchecks](https://github.com/healthchecks/healthchecks). Both UUID and Slug URLs are supported, and the updater works regardless whether the POST-only mode is enabled.
The Uptime Kuma’s Push URL to ping when the updater successfully updates IP addresses, such as `https://
⚠️ The “Heartbeat Interval” should match the update schedule specified by `UPDATE_CRON`.
| | `SHOUTRRR` |Newline-separated [shoutrrr URLs](https://containrrr.dev/shoutrrr/latest/services/overview/) to which the updater sends notifications of IP address changes and other events. In other words, put one URL on each line. Blank lines and lines whose first non-whitespace character is `#` are ignored. Each shoutrrr URL represents a notification service; for example, `discord://
If you configure this value via YAML, prefer literal block style \| over folded style >.
An absolute path to a file containing shoutrrr URLs, one per line. This is the file version of `SHOUTRRR`, useful for keeping URLs that embed secret tokens out of the environment (for example with [Docker secrets](https://docs.docker.com/compose/how-tos/use-secrets/)).
Blank lines and lines whose first non-whitespace character is `#` are ignored. If both `SHOUTRRR` and `SHOUTRRR_FILE` are set, they must specify the same URLs (order does not matter) or the updater refuses to start.
⚠️ The file must be readable by the user configured by `user: "UID:GID"`.
|