Dynamic DNS, Kubernetes-native

A small pod that keeps my records pointing at my router

A home address that keeps changing

A cloud pointing down at a DNS record

Hosting a website at home takes a few things, and one of them is an address. You buy a domain, and that domain has to point at wherever the machine is. The mapping between a name and an address is DNS: a record stored somewhere public that tells everyone where to look when they ask for that name.

Home connections do not keep one address. My provider reassigns mine, and the moment it does, every name pointing at the old one is wrong.

Dynamic DNS is the usual fix: a small tool asks the outside world what your address currently is, and tells your DNS provider when it changes. On Linux that is handled for almost every provider, and I ran those tools on my older homeservers for years.

What I could not find was one that runs the way the rest of my infrastructure runs, so I wrote it.

hetzner-ddns is a small Go program that runs as a Deployment in my cluster, takes its configuration from a ConfigMap, and keeps the A records I list pointed at whatever address my router currently has. There are about 640 lines of Go in it. Most of it was written with AI assistance over about a day, with small fixes two days later.

The only time it comes to mind is when DNS is wrong and I go looking for a reason.

What it does on every pass

A pass starts by finding out what the current address is. It asks three services in order and takes the first one that answers with a 200:

var ipCheckServices = []string{
    "https://checkip.amazonaws.com",
    "https://api.ipify.org",
    "https://icanhazip.com",
}

If one of them is slow or answers with an error, the next one is tried, so a single bad answer does not stop the update.

The address is then compared against every record listed in the config. A record that does not exist gets created. A record holding the wrong address gets corrected. A record whose TTL differs from the file gets its TTL changed. Everything already correct is left alone until the next pass.

It reconciles once when it starts and then on a timer, which I run at 15 minutes. A second ticker re-reads the configuration every minute, so changing that interval in the ConfigMap reloads the file and resets the main ticker to the new value. The pod is never replaced for a config change.

What the config looks like

The whole interface is one YAML file: how often to check, which zones to keep current, and which names inside them should follow the address.

params:
  refresh_interval: 15
hetzner:
  zones:
    - zone_name: "example.com"
      records:
        - name: "@"
          type: "A"
          ttl: 3600
        - name: "www"
          type: "A"
          ttl: 3600
        - name: "mail"
          type: "A"
          ttl: 3600

Both levels are lists, so one Deployment covers as many zones and names as I want rather than one process per hostname. @ is the root, which means the zone itself follows the address too.

Each record carries its own TTL, so how long a stale answer can live in a resolver cache is a number in this file rather than something I would fix later at the provider.

Why it runs in the cluster

The configuration is a ConfigMap in the same Git repository as the rest of my cluster. Changing a record is a commit, the commit message says why, and the history of my DNS sits next to the manifests it belongs to. Nothing gets typed into a file on a machine by hand.

The other reason is that my three nodes are identical. Admin tooling that lives on one of them makes that node more important and more risky than the other two, and leaves me remembering which machine I put it on. As a Deployment it can run anywhere, and losing any single node does not take my DNS updates with it.

In short I did not want a DDNS client in the layer underneath Kubernetes, hidden in a Linux filesystem I rarely open, making my cluster nodes asymmetric in importance.

The migration that made me rewrite the client

Hetzner moved DNS onto a new Cloud API, and it is not the same surface with a different URL. Zones have to be migrated to the new console first, the identifiers for records change, and the token becomes a Cloud token instead of the old DNS one.

There was a deadline and not much time, so I handed the migration guide and the announcement to an AI assistant and followed the steps it gave me. For a homelab I think that is a fair way to get through an API move.

I rewrote the client against the new API instead of carrying both. As far as I can tell I am the only one running this tool, and anyone still on the legacy path could have stayed on the older version, but Hetzner's cutoff moved the zones either way, so a pod on the old client would have stopped working regardless.

What it does not touch

The program only ever writes the records its config mentions, and there is no delete path in it at all. Take a name out of the file and it stops being looked after, while the record stays in the zone exactly as it was.

There is no state either: no database, nothing to back up, and nothing that can drift away from the file. If the pod is replaced, it reads the same configuration and converges on the same records.

There is also no monitoring, no metrics and no stats. It keeps records current and does nothing else.

The code is on GitHub under the MIT license. There are no contributors, and it is ready for anyone who finds it and needs the same thing.

What I would change

Declaring records in a file and reconciling them against a provider is what Crossplane does for cloud resources in general, and a Hetzner DNS provider for it, either on its own or as a feature inside an existing provider, is a better home for this configuration than a small program of my own.

While researching for this article I found there is an existing Hetzner Provider for Crossplane so I might just add DNS management as a feature there in the future.