External DNS with OpenWRT

External DNS with OpenWRT

July 6, 2026

If you run a Kubernetes homelab behind an OpenWrt router, every new
Ingress means another DNS record to add by hand. It works, but it’s
one more manual step to remember, and it doesn’t scale well once
you’re deploying more than the occasional service.

This post shows how to automate that with
external-dns,
using the OpenWrt router already sitting at the edge of the lab.

The lab:

  • Kubernetes cluster — nothing special, any cluster with a working Ingress controller will do
  • OpenWrt router at 192.168.5.1 — dnsmasq handles LAN DNS on port 53, Knot DNS added on port 5300
  • Zone: hq.c1.itknecht.de (adapt to your own domain)

Why Knot on port 5300?

The obvious question: why not just use dnsmasq, which is already there?

The short answer is: dnsmasq cannot do what external-dns needs.
dnsmasq is a lightweight forwarding resolver and DHCP server — it is not
an authoritative DNS server. It has no support for RFC2136 dynamic DNS
updates, no TSIG authentication, no SOA/serial management, and no zone
transfer (AXFR). external-dns requires all of these to operate with the
rfc2136 provider.

The next thought might be: replace dnsmasq with a full authoritative
server. That would mean losing DHCP, LAN hostname resolution, and the
deep UCI integration that makes OpenWrt work the way it does. Not a small
change — it would fundamentally alter the router’s behavior.

Knot DNS solves this without touching any of that. It runs alongside
dnsmasq on port 5300, owns exactly one internal zone, and accepts
TSIG-authenticated dynamic updates from external-dns. dnsmasq keeps
doing everything it did before, and just forwards queries for
hq.c1.itknecht.de to Knot. The router behaves identically to before
from every other perspective.

It is a small, focused addition — a good fit for a router that should
stay boring.

1. Install Knot DNS on OpenWrt

opkg update
opkg install knot knot-keymgr knot-libs knot-libzscanner knot-nsupdate knot-zonecheck

2. Generate a TSIG Key

Knot ships keymgr for key management. Generate a TSIG key that
external-dns will use to authenticate dynamic DNS updates:

keymgr tsig generate externaldns-key algorithm=hmac-sha256

The output is a complete key block — copy the secret value, you will
need it in two places: the Knot config and the Kubernetes Secret.

3. Configure Knot

Create /etc/knot/knot.conf:

server:
    rundir: "/var/run/knot"
    user: knot:knot
    automatic-acl: on
    listen: [ 192.168.5.1@5300 ]

log:
  - target: syslog
    any: info

database:
    storage: "/var/lib/knot"
    journal-db-max-size: 20M
    timer-db-max-size: 5M

remote:

template:
  - id: default
    storage: "/var/lib/knot"
    file: "%s.zone"

key:
  - id: externaldns-key
    algorithm: hmac-sha256
    secret: <your-generated-secret>=

acl:
  - id: externaldns-update
    key: externaldns-key
    action: [update, transfer]
    address: 0.0.0.0/0

zone:
  - domain: hq.c1.itknecht.de
    file: "/etc/knot/zones/hq.c1.itknecht.de.zone"
    acl: externaldns-update

The ACL allows TSIG-authenticated dynamic updates and zone transfers
from any source address — external-dns needs both (--rfc2136-tsig-axfr
does a full zone transfer to enumerate existing records before writing).

4. Create the initial zone file

Knot needs a valid zone on disk before it will accept dynamic updates.
Create /etc/knot/zones/hq.c1.itknecht.de.zone:

$ORIGIN hq.c1.itknecht.de.
@   3600 IN SOA ns1.hq.c1.itknecht.de. admin.itknecht.de. 1 3600 900 604800 86400
    3600 IN NS ns1.hq.c1.itknecht.de.
ns1 3600 IN A 192.168.5.1
ℹ️
Point ns1 at the internal IP of the Knot server (192.168.5.1)

Start and enable Knot:

service knot start
service knot enable
knotc zone-status hq.c1.itknecht.de

5. Forward the zone in dnsmasq

Add one line to /etc/dnsmasq.conf so LAN clients resolve the zone via Knot:

server=/hq.c1.itknecht.de/192.168.5.1#5300

Restart dnsmasq:

service dnsmasq restart

6. Open port 5300 in the OpenWrt firewall

In /etc/config/firewall (or via UCI):

uci add firewall rule
uci set firewall.@rule[-1].src='lan'
uci set firewall.@rule[-1].name='knot@5300'
uci add_list firewall.@rule[-1].proto='udp'
uci set firewall.@rule[-1].dest_port='5300'
uci set firewall.@rule[-1].target='ACCEPT'
uci set firewall.@rule[-1].family='ipv4'
uci commit firewall
service firewall restart

7. Test dynamic updates manually

Before wiring up external-dns, it’s worth confirming a dynamic update
actually works — same mechanism external-dns will use later, just
without the client.

Set a test record with knsupdate (the TSIG-aware update tool that
ships with Knot):

cat <<EOF | knsupdate -y hmac-sha256:externaldns-key:<your-generated-secret>=
server 192.168.5.1 5300
zone hq.c1.itknecht.de
update add test.hq.c1.itknecht.de. 300 A 192.168.5.99
send
EOF

Check that it landed:

dig @192.168.5.1 -p 5300 test.hq.c1.itknecht.de

Remove it again:

cat <<EOF | knsupdate -y hmac-sha256:externaldns-key:<your-generated-secret>=
server 192.168.5.1 5300
zone hq.c1.itknecht.de
update delete test.hq.c1.itknecht.de. A
send
EOF

Confirm it’s gone:

dig @192.168.5.1 -p 5300 test.hq.c1.itknecht.de

If all three steps behave as expected, the ACL and TSIG key are set up
correctly and external-dns can use the same path.

8. Deploy external-dns in Kubernetes

First, store the TSIG secret as a Kubernetes Secret:

kubectl create namespace external-dns
kubectl create secret generic externaldns-tsig \
  --namespace external-dns \
  --from-literal=tsig-secret='<your-generated-secret>='

Then apply the full deployment:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-dns
  namespace: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: external-dns
rules:
  - apiGroups: [""]
    resources: ["services", "endpoints", "pods", "nodes"]
    verbs: ["get", "watch", "list"]
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: external-dns
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: external-dns
subjects:
  - kind: ServiceAccount
    name: external-dns
    namespace: external-dns
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
  namespace: external-dns
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: external-dns
  template:
    metadata:
      labels:
        app: external-dns
    spec:
      serviceAccountName: external-dns
      containers:
        - name: external-dns
          image: registry.k8s.io/external-dns/external-dns:v0.15.1
          args:
            - --source=ingress
            - --domain-filter=hq.c1.itknecht.de
            - --provider=rfc2136
            - --rfc2136-host=192.168.5.1
            - --rfc2136-port=5300
            - --rfc2136-zone=hq.c1.itknecht.de
            - --rfc2136-tsig-keyname=externaldns-key
            - --rfc2136-tsig-secret=$(TSIG_SECRET)
            - --rfc2136-tsig-secret-alg=hmac-sha256
            - --rfc2136-tsig-axfr
            - --registry=txt
            - --txt-owner-id=hq-c1
            - --policy=upsert-only
            - --interval=1m
          env:
            - name: TSIG_SECRET
              valueFrom:
                secretKeyRef:
                  name: externaldns-tsig
                  key: tsig-secret

A few things worth noting:

  • --rfc2136-tsig-axfr enables zone transfer to enumerate existing records — required for the TXT registry to work correctly
  • --policy=upsert-only means external-dns only creates/updates records, never deletes them; safe for a homelab
  • --registry=txt with --txt-owner-id lets external-dns track which records it owns via TXT records

9. Verify

Check that the external-dns pod is running and writing records:

kubectl -n external-dns logs -f deploy/external-dns

After the first sync interval (1 minute), query the zone directly:

dig @192.168.5.1 -p 5300 myapp.hq.c1.itknecht.de

Or via dnsmasq (standard port, as LAN clients would):

dig @192.168.5.1 myapp.hq.c1.itknecht.de

Check the serial increment on the router — each external-dns write bumps it:

knotc zone-status hq.c1.itknecht.de

Links

Last updated on