Kubernetes

Setting up external-dns with DynIP

This guide walks you through running external-dns against a DynIP zone. By the end you'll have Kubernetes Services and Ingresses that automatically publish DNS records to a domain you control.

What this is for

If you're running Kubernetes, you've probably hit this pattern: you deploy a Service, it gets an IP, and now you need to make it reachable by a hostname. You can manually add a record every time, or you can let external-dns watch your cluster and publish records based on annotations.

external-dns with DynIP gets you:

  • DNS records that follow your workloads (deploy → record appears; delete → record disappears with policy=sync)
  • A single zone you fully control, no shared subdomains
  • TSIG-signed updates over RFC 2136 (the same protocol BIND nsupdate uses)
  • DNSSEC-signed responses, IPv6, all the standard DNS niceties

If you've used external-dns with Route53 or Cloudflare, the workflow is identical — only the provider configuration changes.

What you'll need

  • A Kubernetes cluster you can kubectl apply to. Any reasonable version works (1.24+). Examples in this guide use k3d for a local cluster, but any cluster is fine.
  • A DynIP account with a zone provisioned. We'll use example.ddns.dynip.dev throughout; substitute your own zone.
  • The zone's TSIG algorithm set to HMAC-SHA256 (the DynIP default for new zones; older zones may need a flip in the dashboard's Options modal).

Step 1: Verify your zone is ready

Open the dashboard and find your zone. Check two things:

  1. TSIG Algorithm: Click Options. The algorithm should be HMAC-SHA256. If it's HMAC-MD5, toggle it (the secret stays the same, only the algorithm changes). external-dns refuses HMAC-MD5 and won't start without sha256.
  2. The zone resolves: From your laptop, run:
    dig @ns1.dynip.dev example.ddns.dynip.dev A +short
    You should get back the current IP for your zone. If you get nothing, the zone isn't fully provisioned yet — wait a couple minutes and try again.

Step 2: Generate the manifest

In the dashboard, click Snippets on your zone, choose external-dns (Kubernetes), and fill in the Cluster Name field.

About the cluster name: external-dns uses this to track which records it owns. Pick something descriptive and unique to this cluster — prod-eu, staging, homelab, etc. If you run multiple clusters against the same zone, each must have a different cluster name. If two clusters share a name, they'll fight over records.

Copy the generated YAML to a file. Call it whatever you like — external-dns.yaml for this guide.

The manifest contains:

  • A ServiceAccount (external-dns) for the controller to run as
  • A ClusterRole granting read access to Services, Ingresses, and Endpoints
  • A ClusterRoleBinding wiring them together
  • The external-dns Deployment, configured to talk to your zone

Everything goes into the default namespace by default. If you'd rather put it elsewhere, change the namespace references in the YAML before applying.

Step 3: Deploy external-dns

kubectl apply -f external-dns.yaml

You should see:

serviceaccount/external-dns created
clusterrole.rbac.authorization.k8s.io/external-dns created
clusterrolebinding.rbac.authorization.k8s.io/external-dns-viewer created
deployment.apps/external-dns created

Watch it start:

kubectl logs -f deployment/external-dns

A clean startup looks like this (after a long config dump):

Configured RFC2136 with zone '[example.ddns.dynip.dev]' and nameserver 'update.dynip.dev:53'
All records are already up to date

If you see hmac-md5 is not supported TSIG algorithm instead — go back to Step 1, your zone is on MD5.

Step 4: Publish your first record

Create a test Service with an external-dns annotation:

# test-app.yaml
apiVersion: v1
kind: Service
metadata:
  name: test-app
  namespace: default
  annotations:
    external-dns.alpha.kubernetes.io/hostname: app.example.ddns.dynip.dev
    external-dns.alpha.kubernetes.io/target: "203.0.113.42"
spec:
  type: ClusterIP
  ports:
  - port: 80
  selector:
    app: test-app

203.0.113.42 is a documentation IP — in real use, you'd typically point at a LoadBalancer IP, an Ingress endpoint, or your cluster's egress address. For testing, any IP works.

kubectl apply -f test-app.yaml

Within 30 seconds, external-dns will publish the record:

dig @ns1.dynip.dev app.example.ddns.dynip.dev A +short
# Expect: 203.0.113.42

Check the external-dns logs to see what happened:

kubectl logs deployment/external-dns | grep -i "adding rr"

You'll see three records added: the A record, plus two TXT records (_externaldns.app... and _externaldns.a-app...) that external-dns uses to track ownership. Don't delete those manually — external-dns relies on them to know which records are its responsibility.

Step 5: Understand the policy modes

The generated snippet defaults to policy=upsert-only. This is the recommended starting point because it's safe — external-dns will create and update records, but never delete them.

Try changing the target IP:

# Edit test-app.yaml, change target from 203.0.113.42 to 203.0.113.55
kubectl apply -f test-app.yaml
sleep 35
dig @ns1.dynip.dev app.example.ddns.dynip.dev A +short

With upsert-only, you'll see BOTH 203.0.113.42 AND 203.0.113.55. The old value stuck around. external-dns added the new one but didn't remove the old one — that's the "never delete" guarantee.

This is fine for some use cases (you may want the historical record), but often you want clean transitions. That's what policy=sync is for.

Switching to sync mode

In your external-dns.yaml, find this section:

- --interval=30s
- --policy=upsert-only
# To enable deletes (policy=sync), replace the --policy line above
# with the two lines below ...
# - --policy=sync
# - --rfc2136-tsig-axfr

The two --policy=sync and --rfc2136-tsig-axfr lines should be uncommented. The --policy=upsert-only line should be commented out.

Apply:

kubectl apply -f external-dns.yaml
kubectl rollout restart deployment/external-dns

Now external-dns will:

  • Read the zone via AXFR on each reconcile
  • Compare desired state (from your cluster) to actual state (from DNS)
  • Add, update, AND delete to make them match

Delete the test Service and watch the record disappear:

kubectl delete service test-app
sleep 35
dig @ns1.dynip.dev app.example.ddns.dynip.dev A +short
# Expect: (nothing)

Clean. The record and its ownership TXTs are gone.

Common patterns

Ingress with multiple hostnames

If you use Ingress instead of Service, external-dns reads host from each rule and publishes a record for it:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app
  annotations:
    external-dns.alpha.kubernetes.io/target: "203.0.113.42"
spec:
  rules:
  - host: app.example.ddns.dynip.dev
    http: {...}
  - host: api.example.ddns.dynip.dev
    http: {...}

external-dns publishes both app. and api. records pointing at the target.

LoadBalancer services

If your cluster has a working LoadBalancer (cloud provider, MetalLB, etc.), external-dns can pick up the assigned IP automatically — just drop the target annotation:

metadata:
  annotations:
    external-dns.alpha.kubernetes.io/hostname: app.example.ddns.dynip.dev
spec:
  type: LoadBalancer

When the LoadBalancer gets an IP, external-dns publishes a record pointing at it.

Multi-cluster

Run external-dns in each cluster with a different --txt-owner-id:

  • Cluster A: --txt-owner-id=prod-eu
  • Cluster B: --txt-owner-id=prod-us

Each cluster manages its own records. Records published by A have ownership TXTs with owner=prod-eu; cluster B's external-dns sees them, realizes they're not its records, and ignores them.

Troubleshooting

Records create fine but don't update or delete

Check your policy:

kubectl get deployment external-dns -o yaml | grep policy

If it's upsert-only, that's expected — switch to sync to enable updates and deletes (see Step 5).

hmac-md5 is not supported

Your zone's key is MD5. Switch to SHA-256 in the dashboard Options modal. The TSIG secret stays the same.

AXFR error: dns: bad xfr rcode: 9

Authorization issue on AXFR. external-dns will fall back to "update without reading state" — UPDATEs work, deletes don't. If this persists, contact support.

flag parsing error: unexpected true

--rfc2136-tsig-axfr is a boolean — don't write --rfc2136-tsig-axfr=true, just --rfc2136-tsig-axfr. The snippet generator has this correct; if you edited the manifest manually, double-check.

Stale records under upsert-only

This is expected behavior. Either:

  • Switch to policy=sync for automatic cleanup
  • Manually delete stale records via the DynIP API or DNS UPDATE

Records get deleted by external-dns that shouldn't be

Check --txt-owner-id is unique to this cluster. If two clusters share an owner-id, they'll fight over records. Each cluster must have a different owner-id.

What's next