My homelab runs Traefik as a reverse proxy for all Docker containers. Every time I add a new service I have two jobs: add the Traefik router label to the compose file, then go and manually create the DNS record in vetinari, my Samba AD DC. The second step is tedious and I keep forgetting it, so services end up with working certificates and no DNS entry. I wanted something that watches Traefik and creates the DNS records automatically.

The Environment

The homelab internal domain is discworld.network. Vetinari is a Samba AD DC that is the authoritative DNS server for everything on the network. docker1 is a Proxmox VM that runs all Docker containers via Portainer stacks. Traefik runs on docker1 and all services get Host() rules pointing at subdomains of ankh-morpork.discworld.network.

Why Not Just Use external-dns

The obvious tool for this is external-dns. It has a Traefik source mode that reads directly from the Traefik API and an RFC 2136 provider for dynamic DNS updates. The problem is that the official image is distroless. No shell, no kinit, no Kerberos libraries. GSS-TSIG authenticated dynamic updates require kinit and the Kerberos tooling to be present in the container, and there is no way to add them to a distroless image without rebuilding from source.

I looked at custom image builds on top of the external-dns base and immediately hit the second problem: you cannot apt-get install into a distroless image. You would need a multi-stage build pulling the binary out and dropping it into a Debian base. That is a maintenance burden for something that is ultimately just polling an API and running nsupdate.

Writing a small script and putting it in a proper Debian-based container turned out to be less work than fighting the external-dns image.

The Approach

Traefik exposes a REST API at /api/http/routers that lists every router it knows about, including the rule and provider. A docker-provider router with a Host() rule is all we need. Poll that endpoint on an interval, extract the hostnames, diff against the previous cycle, and call nsupdate for anything that changed.

For authentication, Samba AD supports GSS-TSIG dynamic DNS updates using Kerberos. This is how Windows clients register their own DNS records. nsupdate -g handles the GSS-TSIG negotiation automatically once you have a valid Kerberos ticket. The ticket comes from a keytab, which gets stored as a Docker secret. No passwords, no SSH, no broad privilege.

Setting Up the Samba Service Account

On vetinari:

samba-tool user create dns-updater --random-password
samba-tool group addmembers DnsAdmins dns-updater
samba-tool domain exportkeytab /etc/samba/dns-updater.keytab \
  --principal=dns-updater@DISCWORLD.NETWORK

Verify it works:

kinit -kt /etc/samba/dns-updater.keytab dns-updater@DISCWORLD.NETWORK
klist

One thing worth knowing: samba-tool exports the keytab to the path you specify, not /tmp, so do not copy the kinit command from somewhere else and expect it to work against a different path.

Joining docker1 to the Domain

GSS-TSIG requires the host making the nsupdate call to have a valid Kerberos context, which means docker1 needs to be joined to the domain. The standard tooling for this on Debian is realmd.

apt install sssd-ad sssd-tools realmd adcli krb5-user \
  packagekit samba-common-bin libnss-sss libpam-sss

realm join discworld.network -U administrator

There is a known issue with realmd where it refuses to proceed claiming packages are not installed even when they are. This happens because realmd uses PackageKit to check and install dependencies, and if PackageKit is not running it falls through to an error rather than checking the installed packages directly. Installing and starting PackageKit fixes it:

apt install packagekit
systemctl start packagekit
realm join discworld.network -U administrator

Docker Secret for the Keytab

I run Swarm mode on docker1 for secrets support. The keytab transfer went via a local machine since neither vetinari nor docker1 allow root logins directly:

# On vetinari - make it readable temporarily
sudo cp /etc/samba/dns-updater.keytab /tmp/dns-updater.keytab
sudo chown [ADMIN_USER]:[ADMIN_USER] /tmp/dns-updater.keytab

# Local machine
scp [ADMIN_USER]@vetinari:/tmp/dns-updater.keytab .
scp dns-updater.keytab [ADMIN_USER]@docker1:/tmp/
shred -u dns-updater.keytab

# On vetinari - clean up
sudo shred -u /tmp/dns-updater.keytab

# On docker1
sudo docker secret create dns-updater-keytab /tmp/dns-updater.keytab
sudo shred -u /tmp/dns-updater.keytab

The Script

The sync script is pure Python stdlib. No dependencies to manage, no requirements.txt, nothing to update. It runs in a Debian bookworm-slim container with krb5-user and dnsutils installed.

The core loop is straightforward. kinit with the keytab to get a fresh ticket, fetch the Traefik router list, extract hostnames from Host() rules on docker-provider routers, diff against the previous cycle’s state, run nsupdate for any changes.

def nsupdate(commands):
    result = subprocess.run(
        ['nsupdate', '-g'],
        input=f"{commands}\n",
        capture_output=True, text=True
    )
    if result.returncode != 0:
        raise RuntimeError(f"nsupdate failed: {result.stderr}")

def add_record(hostname):
    nsupdate(f"""server {DNS_SERVER}
zone {DNS_ZONE}
update add {hostname}. {TTL} A {TARGET_IP}
send""")

One mistake I made in the first version: I included a gss-tsig line in the nsupdate input, thinking it needed to be declared explicitly. It does not. The -g flag on nsupdate is what enables GSS-TSIG negotiation. The gss-tsig string is not a valid nsupdate directive and causes an immediate syntax error. The correct input is just the update commands with no preamble.

The Dockerfile:

FROM debian:bookworm-slim

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        python3 \
        krb5-user \
        dnsutils \
        ca-certificates && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY dns_sync.py .
COPY krb5.conf /etc/krb5.conf

USER nobody
ENTRYPOINT ["python3", "/app/dns_sync.py"]

The krb5.conf is needed because the Debian base image has no knowledge of your realm:

[libdefaults]
    default_realm = DISCWORLD.NETWORK
    dns_lookup_realm = false
    dns_lookup_kdc = true

[realms]
    DISCWORLD.NETWORK = {
        kdc = [DC_IP]
        admin_server = [DC_IP]
    }

[domain_realm]
    .discworld.network = DISCWORLD.NETWORK
    discworld.network = DISCWORLD.NETWORK

The compose stack uses the Docker secret and passes configuration via environment variables. All the site-specific values are externalised so the image itself is generic:

version: "3.8"

services:
  dns-traefik-sync:
    image: git.ankh-morpork.discworld.network/laurence/dns-traefik-sync:latest
    environment:
      TRAEFIK_API: "http://[TRAEFIK_HOST]:8080"
      DNS_SERVER: "[DC_IP]"
      DNS_ZONE: "ankh-morpork.discworld.network"
      TARGET_IP: "[DOCKER_HOST_IP]"
      TTL: "300"
      POLL_INTERVAL: "60"
      KEYTAB: "/run/secrets/dns-updater-keytab"
      KRB_PRINCIPAL: "dns-updater@DISCWORLD.NETWORK"
    secrets:
      - dns-updater-keytab
    restart: unless-stopped

secrets:
  dns-updater-keytab:
    external: true

Forgejo Container Registry

The image is built and pushed to my self-hosted Forgejo instance at git.ankh-morpork.discworld.network. Forgejo has a built-in container registry that just needs enabling in app.ini:

[packages]
ENABLED = true

Restart Forgejo and the registry is available at the same hostname. docker login and docker push work against it directly. I push manually for now. The repo has a Gitea Actions workflow for automated builds on version tags, but that requires a runner to be configured which I have not done yet.

Portainer’s git integration failed because Portainer does not trust my internal stepca CA. I told it to skip TLS verification for the git clone, which is fine for a private internal instance. The stack gets deployed via the Portainer editor with the compose file pasted in directly.

The Result

On first run the logs showed exactly what I wanted:

2026-05-24 18:00:00 INFO dns-traefik-sync starting
2026-05-24 18:00:00 INFO Kerberos ticket obtained
2026-05-24 18:00:00 INFO Adding grafana.ankh-morpork.discworld.network -> [DOCKER_HOST_IP]
2026-05-24 18:00:00 INFO Adding prometheus.ankh-morpork.discworld.network -> [DOCKER_HOST_IP]
2026-05-24 18:00:00 INFO Adding authentik.ankh-morpork.discworld.network -> [DOCKER_HOST_IP]
2026-05-24 18:00:00 INFO Adding adguard.ankh-morpork.discworld.network -> [DOCKER_HOST_IP]
2026-05-24 18:00:00 INFO Adding netbox.ankh-morpork.discworld.network -> [DOCKER_HOST_IP]
2026-05-24 18:00:00 INFO Adding home.ankh-morpork.discworld.network -> [DOCKER_HOST_IP]
2026-05-24 18:00:00 INFO Adding git.ankh-morpork.discworld.network -> [DOCKER_HOST_IP]

Every service got its DNS record in a single poll cycle. Subsequent cycles show no changes because the state is tracked across runs. New containers pick up DNS entries within 60 seconds of coming up and records are removed when containers go away.

Limitations

A records only, single target IP, single zone. That covers everything I need right now. CNAME support would be useful for multi-host setups and is an obvious extension if the setup ever grows to split services across hosts.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.