Self-hosted service
timothymiller/cloudflare-ddns avatar
timothymiller/cloudflare-ddns

Cloudflare DDNS in Rust: A 1.1 MB Container That Refuses to Write a Bad Record

🦀 Rust based dynamic DNS (DDNS) updater for Cloudflare

4,511 stars432 forksRustGPL-3.0

At a glance

What is it?
timothymiller/cloudflare-ddns is a GPL-3.0 dynamic DNS updater that keeps Cloudflare A and AAAA records pointed at your home connection. Its distinguishing design choice is what it does when IP detection fails, and that is the part worth evaluating before you deploy it.
Who is it for?
Adopt it if you run a self-hosted service behind a residential or CGNAT connection and you want DNS updates driven by environment variables rather than a config file plus a daemon reload. Skip it if your address changes faster than your cron interval, if you need a provider Cloudflare does not host DNS for, or if you want a client that manages one record for one host and nothing else.
Can I use it commercially?
Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
Is it still maintained?
Yes. The repository last received commits 2 days ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 15, 2026, and from our analysis. They are not legal advice.

DEEP OPEN-SOURCE ANALYSIS

The problem is a stale A record, not a missing DDNS client

Anyone running a home server has the same problem. The ISP hands out an address that changes on a router reboot, a lease expiry, or a maintenance window, and the domain name that points at it goes stale silently. The README frames the goal plainly: access your home network remotely via a custom domain name without a static IP. What makes this project narrower than that sentence suggests is the Cloudflare dependency. It is not a general DDNS client that speaks RFC 2136 or the dyndns2 protocol. It talks to the Cloudflare API, and it manages records inside zones you already control there. If your DNS is not on Cloudflare, nothing in this repository helps you. The audience is therefore specific: people who already use Cloudflare as their authoritative DNS, who want to keep that, and who would rather run one small container than maintain a cron job with a curl script that has no notion of failure states. The environment variable surface is broad enough that the intended user is comfortable with Docker flags and shell quoting, not someone looking for a web UI.

Detection, rejection, then a single API write

One update cycle has three stages. First, the client resolves an address using the provider configured for that IP family. IP4_PROVIDER defaults to ipify; IP6_PROVIDER defaults to cloudflare.trace. The provider list is where most of the design lives. cloudflare.trace reads Cloudflare's /cdn-cgi/trace endpoint, which the README describes as zero-log. cloudflare.doh issues a DNS-over-HTTPS TXT query against whoami.cloudflare. local reads the system routing table with no network traffic at all, and the README notes it is CGNAT-aware, filtering shared address space in 100.64.0.0/10 and private ranges. local.iface:<name> pins detection to a named interface, and local.iface.stable:<name> is the Linux-specific variant that prefers a stable IPv6 address while excluding temporary and deprecated ones. url:<url> points at any HTTP(S) endpoint that returns an address, and literal:<ips> skips detection entirely with a fixed value. Second, if REJECT_CLOUDFLARE_IPS is true (the default), the cycle fetches Cloudflare's published IP ranges and discards any detected address that falls inside them. That guard exists because some detection providers occasionally return a Cloudflare anycast address instead of the real public IP, and writing that into an A record points your hostname at Cloudflare's own infrastructure. Third, the client writes the surviving address to the matching records across the zones named in DOMAINS, IP4_DOMAINS, IP6_DOMAINS, or WAF_LISTS. The README also documents a managed record regex, which controls which existing records the tool is willing to touch. That is the mechanism that keeps it from clobbering records you created by hand.

Getting it running: one docker run and a token

The README's quick start is a single command. docker run -d --name cloudflare-ddns --restart unless-stopped --network host -e CLOUDFLARE_API_TOKEN=your-api-token -e DOMAINS=example.com,www.example.com timothyjmiller/cloudflare-ddns:latest. The documentation states that the container then detects the public IP and updates those records every five minutes, which is the default UPDATE_CRON value of @every 5m. The --network host flag is not optional decoration: the README says it is required to detect IPv6 addresses, and that if you only need IPv4 you can drop it and set IP6_PROVIDER=none. Authentication is either CLOUDFLARE_API_TOKEN or CLOUDFLARE_API_TOKEN_FILE, the latter taking a path to a file so it works with Docker secrets. The token needs Edit DNS capability, generated from the Cloudflare profile page. At least one of DOMAINS, IP4_DOMAINS, IP6_DOMAINS, or WAF_LISTS must be set. Scheduling is controlled by UPDATE_CRON with values like @every 5m, @every 1h, @every 30s, or @once. The @once mode has a constraint worth noting: UPDATE_ON_START must be true and DELETE_ON_STOP must be false, or the configuration is invalid. UPDATE_ON_START defaults to true, DELETE_ON_STOP and DELETE_ON_FAILURE default to false. A dry-run mode exists for previewing changes without touching records, and the README lists record comments and a managed record regex as additional controls.

The failure model is the actual feature

Most DDNS scripts have one behaviour when detection fails: they log an error and retry later. This project separates two cases, and the distinction is the most interesting thing in the README. A transient failure is a network-based provider erroring out, or every detected address being rejected as a Cloudflare IP. In that case the documentation states that existing DNS records are never deleted or overwritten. The update is simply skipped. The second case is a definitive report of no address of that family, and only then does DELETE_ON_FAILURE, if you have enabled it, remove the managed records. That default of false matters. It means an outage at ipify or a temporary routing problem produces no record churn, just a warning. The same caution shows up in the Cloudflare IP rejection path: if the published ranges cannot be fetched, the README says the update is skipped entirely rather than proceeding without the check. You can see the trade-off clearly. A client that always writes the latest detected address will eventually write a wrong one during a provider glitch, and a client that refuses to write when anything is uncertain will leave a stale record in place during a genuine address change. This project picks the second failure mode on purpose, and for a home server that is usually the right call, because a stale record is recoverable and a record pointing at Cloudflare anycast is confusing to debug.

Where it is the wrong tool

The scheduling model is the first constraint. Updates happen on a cron expression, not on an event. If your ISP rotates addresses more often than your UPDATE_CRON interval, there is a window where the record is wrong, and nothing in the README suggests a push-based or router-triggered update path. Shortening the interval to @every 30s trades that window for more API calls and more detection traffic, and the README gives no rate limit guidance to calibrate against. Second, the local providers are platform-specific in ways the documentation only partly spells out. local.iface.stable:<name> is described as a Linux network interface feature, and the CGNAT filtering applies to the local detection path. On a host behind carrier-grade NAT, no amount of local interface reading produces a routable public address, so you are back to a network provider. Third, the IPv6 story depends on host networking. Running the container on a bridge network with IPv6 domains configured will not work as documented. Fourth, this is a DNS writer, not a DNS manager. It updates records that match its configuration and its managed record regex. It does not provision zones, handle delegation, or replace a configuration management system. If your records are generated by Terraform or a similar tool, you now have two writers racing on the same objects, and the README offers no locking story beyond the regex filter.

Compared with ddclient and the dyndns2 ecosystem

ddclient is the long-standing alternative most people encounter first, and the difference is architectural rather than cosmetic. ddclient is a Perl daemon driven by a configuration file, with a broad set of protocol backends including dyndns2, and it supports providers far beyond Cloudflare. This project inverts both choices. Configuration is entirely environment variables, which suits container orchestration and Docker secrets but is awkward for anything with complex per-record logic. The provider set is Cloudflare-specific: there is no dyndns2 backend, because the client speaks the Cloudflare API directly. That narrower scope is what allows the Cloudflare-specific behaviour that ddclient has no reason to implement, namely fetching Cloudflare's published IP ranges and refusing to write an address inside them, plus WAF list management and record comments. The packaging difference is also real. The README claims a roughly 1.1 MB Docker image and roughly 3.5 MB of RAM at runtime, built as a fully static binary from scratch with zero runtime dependencies, and positions that against Go-based alternatives. Treat those figures as the project's own claims rather than measured results, but the architectural claim behind them is verifiable from the repository: a Rust binary with no interpreter in the image. A Perl daemon needs a Perl runtime. If you are deploying to a small ARM board or a router-adjacent box, that difference is the whole argument.

Maintenance cost, release cadence and the GPL

The release history in the supplied material shows three versions between late April and late July 2026: v2.1.1 with a rand 0.10 fix, failure-safe delete and jitter; v2.1.2 with notification and domain casing fixes; and v2.2.0 adding Zulip notifications, safer failure handling and a Helm chart. Two of those three releases touch failure handling, which tells you where the maintainer's attention has been. The fixes are small and specific, which is what you want in a client whose job is to write one record correctly. Upgrade cost is close to zero if you pin the image tag and read the release notes, because configuration is environment variables and there is no on-disk state to migrate apart from the records themselves. The notification surface is Shoutrrr-compatible, covering Discord, Slack, Telegram, Gotify, Pushover, Zulip and generic webhooks, so adding alerting does not add a dependency. On licensing: the repository is GPL-3.0. Running the container against your own Cloudflare account is use of the software, not distribution of it. The obligation attaches if you redistribute the image or a modified binary, and the GPL-3.0 requires that recipients get the corresponding source under the same terms. If you are embedding this in a product you ship, that is a question for your own counsel, not something this article can settle. If you are running it on a home server or an internal VM, the licence is unlikely to be the deciding factor.

Editorial conclusion

Adopt it if you run a self-hosted service behind a residential or CGNAT connection and you want DNS updates driven by environment variables rather than a config file plus a daemon reload. Skip it if your address changes faster than your cron interval, if you need a provider Cloudflare does not host DNS for, or if you want a client that manages one record for one host and nothing else. Before trusting it with a production hostname, run it once with dry-run mode against your real token and domains, confirm the detected address matches what a browser reports, and check whether REJECT_CLOUDFLARE_IPS is leaving your records untouched because your provider is handing back an anycast address.

Official sources

  1. License: GPL-3.0
  2. Project website
  3. README
  4. Releases
  5. timothymiller/cloudflare-ddns on GitHub
Community notes

Community notes