Declarative Cloudflare DNS With NixOS

Header image for Declarative Cloudflare DNS With NixOS

Introduction

Since my homelab journey began in 2020, I have now found myself hosting many services for myself and other people. This includes many websites (some for businesses), email records, a fully compliant XMPP server (using Prosody) and more.

Managing DNS records for all this takes time and is a total headache. It usually involves logging into an unfamiliar UI, reading through documentation to understand how to use that company’s particular system, then manually adding the records. Usually the system is slow and clunky, and there is no version control or ability to roll-back configuration changes.

I recently decided to migrate all my DNS records to Cloudflare, as they were spread out across several different providers. This drift was caused by changing providers every few years, but not bothering to migrate DNS records, because it is a manual process, and always such a pain. So they end up staying where they are.

This prompted me to build a system to automatically add and update DNS records using the Cloudflare API. I decided to build this into my Nixos configuration to make it declarative, automatic, and able to inherit configuration variables used by all my systems and machines.

This gives me the following benefits:

  • Version control of my DNS records using Git. Every change is a commit, so I can see what changed, when, and why.
  • Roll back in case of failure. If I accidentally mess something up, I can immediately roll back the configuration to a previous generation that was known to work.
  • Deployment is easy and automatic. Adding or updating a record is a few lines of code. The pipeline and infrastructure are already in place, so after making the changes in a local clone of my nixos repository I type a single command (colmena), the system rebuilds, and the DNS update services run automatically.
  • The same process for declaring records for every domain. I no longer need to log into a different UI and read a different set of documentation for each provider. It also means I am not tied to Cloudflare. Moving to another provider means writing a new sync script, not manually re-entering every record, which is what stopped me migrating them in the first place.
  • Ability to tie this in with the rest of my configuration. This means that I can declare variables in one place, and for that to permeate to all my machines and systems. For example, if I change the IP address of where this website is hosted, that is a single line of code that needs changing, and means that every machine and system can be updated at once. From DNS, to Fail2ban, to metrics collection endpoints, and SSH.

Design

DNS records are declared in a NixOS module as part of my NixOS configuration. The module generates a JSON file containing the records, which is then fed into a Python script that syncs them with the DNS provider (Cloudflare in this case).

The script keeps no state file. On every run it reads the records that are currently live in the zone and diffs them against the JSON file, making one API call per change. Any record that is missing is created, any record whose content differs is updated, and any record that is live but absent from the JSON file is deleted (if prune=true is set, see caveats below). If nothing differs, the script makes no calls and exits.

Diffing against the live zone rather than a local cache means there is no state to lose or get out of step, and the script is idempotent (i.e. safe to run multiple times).

I chose not to use Terraform or OpenTofu for this. They are general purpose tools for building infrastructure, and they create a state file that needs to be stored and backed up. I am only managing a list of DNS records, and there is nothing to track, as a record can be found again from the provider by its type, name and content.

There are also dedicated tools for DNS in nixpkgs, such as dnscontrol and octodns. They already do this job well, and I could have wrapped one of them in a module instead. However, I chose to write my own because it is more direct: Nix, to a JSON file, to the Cloudflare API, with nothing in between. I wrote the module and the script, so if something breaks I know where to look. Owning the script also means I can extend it. I can have it report how many records it created, updated, or left as orphans, and feed that into my existing Prometheus and Grafana setup, or have it message me over XMPP if a record creation is successful or failed.

If my needs ever grow, or I come up against limitations, I may replace the python script with OpenTofu or dnscontrol instead, but the module can still be reused.

Caveats

This approach has some dangers. For example, any accidental modification of the DNS records will still be synced to the provider.

Setting the DNS records is now abstracted away from the manual process (where you can double or triple check before hitting “apply”). Instead, it’s a few lines in a Nixos configuration file that wont get looked at often.

To manage this, I’ve added a per-zone option to the module called prune, set to false by default. When set to true, the script will delete any record in the zone that is not in the NixOS configuration. When set to false, records that are removed from the NixOS configuration are left as orphans in the zone, which can be pruned later using tools/dns-prune.sh.

So, depending on the option that is chosen, there are different risks to be aware of.

The first is when prune is set to true. You need to be careful not to accidentally modify or empty the records for the zone if this is not intended. This could happen during a careless merge for example. Also, records that are manually added outside of this script (i.e. in the UI) will get removed during the next rebuild. So when prune = true is set, the NixOS configuration is the only source of truth for that zone.

The alternative is setting prune = false for a zone (the default value if not explicitly set). Records that are removed from the NixOS configuration will not be removed at the provider. They are no longer tracked in the configuration and are listed as ORPHAN in the logs, but they persist at the provider, unmodified. The issue with this approach is that over time, orphan records can silently accumulate if they are not manually pruned. The logs are easy to miss, because the sync is started with systemctl start --no-block during activation, so its output goes to the journal rather than the nixos-rebuild output. Orphans are also created by changes that don’t look like deletions: changing a record’s content updates it in place, but changing its name or type creates a new record and leaves the old one behind.

With this in mind, you have to decide which risk trade-off is appropriate for your zones and use case; up-to-date and accurate records with the risk of unknowingly removing important records, or a zone that accumulates stale orphan records over time, which must be manually removed.

prune = false is set as the default, as I felt this was the less risky behaviour. Accumulating orphan DNS records is less of an issue than accidentally deleting essential records, leaving services unreachable and mail bouncing.

Implementation

The module, called cloudflare-records exists here in my git repo, and the python script is packaged here.

Usage of the module looks like this:



{config, inputs, ...}: {
  imports = [
    inputs.self.nixosModules.dns.cloudflareRecords
  ];

  sops.secrets."software/cloudflare/apiKey" = {};

  sops.templates."cloudflare-dns-sync.env".content = ''
    CLOUDFLARE_API_TOKEN=${config.sops.placeholder."software/cloudflare/apiKey"}
  '';

  local.dns.cloudflareRecords = {
    # API token needs to be passed as a path to a systemd env file. I manage
    # secrets with sops-nix
    apiTokenFile = config.sops.templates."cloudflare-dns-sync.env".path;
    accountId = "abc123"; # accountID from cloudflare
    zones = {
      # zone or domain to create records from
      "example.com" = {
        enable = true;
        authEmail = "mail@example.com";
        prune = false; # false by default
        records = [
          {
            type = "A";
            name = "@";
            content = "104.20.23.154";
          }
          {
            type = "CNAME";
            name = "www";
            content = "example.com";
          }
        ];
      };
    };
  };
}

I then rebuild remotely using colmena, with the following command:


colmena apply --on host test -v --build-on-target

After building, the logs look like so:


Aug 12 08:31:08 host1 cloudflare-dns-sync[79214]: CREATE A  example.com -> 104.20.23.154
Aug 12 08:31:08 host1 cloudflare-dns-sync[79214]: CREATE A  www.example.com -> 104.20.23.154
Aug 12 08:31:08 host1 cloudflare-dns-sync[79214]: created: A  example.com -> 104.20.23.154 
Aug 12 08:31:08 host1 cloudflare-dns-sync[79214]: created: A  www.example.com -> 104.20.23.154

Then if I want to update a record, the logs look like so:


Aug 12 10:08:10 host1 cloudflare-dns-sync[97064]: UPDATE A  example.com -> 172.66.147.243  (was: 104.20.23.154)
Aug 12 10:08:11 host1 cloudflare-dns-sync[97064]: updated: A  example.com -> 172.66.147.243

Conclusion

It took a few hours to build this. I used Claude to help with the Python script. However, I believe this is well worth the effort. Any DNS records that need to be added or modified in the future will be extremely fast and easy to do. If I setup a new website for someone, or need to configure custom email domains, this module will really help streamline that process.

I’ve opted to set prune=false for most of the zones, as I have email DNS records set-up, and I host business sites for some of my family. I am somewhat paranoid that I will inadvertently delete these records, so better to leave them as orphans instead.

I’ll monitor for the time being. If for some reason all of those services unexpectedly go down after a rebuild, I can look to this module first. At least I’ll be able to run nixos-rebuild switch --rollback to revert the broken records (of course, with prune=false I’ll need to manually remove any orphans).