How to Use External DNS in Kubernetes

How to Use External DNS in Kubernetes

Anyone who has manually created a DNS record every time a new Ingress goes live knows how quickly that becomes tedious, and how easily it gets forgotten during a teardown, leaving stale records pointing at nothing. ExternalDNS automates this entirely: it watches Kubernetes resources and keeps a real DNS provider in sync with what’s actually running in the cluster.

What ExternalDNS Does

ExternalDNS is a controller that watches Services and Ingresses (and a few other resource types) for hostnames, then creates, updates, or removes matching records in an external DNS provider — Route 53, Cloud DNS, Azure DNS, Cloudflare, and dozens of others. It closes the loop that Ingress controllers leave open: an Ingress controller gets traffic to the cluster once DNS points at it, but something still has to make DNS point there in the first place.

Architecture Overview

ExternalDNS runs as a Deployment inside the cluster. On each sync interval, it:

  1. Lists Services/Ingresses with relevant annotations.
  2. Compares desired records against the actual state in the DNS provider (via provider API).
  3. Reconciles differences — creates missing records, updates changed ones, deletes orphaned ones (if --policy=sync is set).

This is the same reconciliation pattern Kubernetes controllers use internally, just applied to an external system instead of the cluster’s own etcd state.

Installing ExternalDNS via Helm (AWS Route 53 Example)

helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update

helm install external-dns external-dns/external-dns \
  --namespace external-dns --create-namespace \
  --set provider=aws \
  --set aws.region=us-east-1 \
  --set policy=sync \
  --set txtOwnerId=my-cluster \
  --set domainFilters={example.com}

txtOwnerId matters more than it looks — ExternalDNS writes TXT records to track which records it owns, so multiple clusters or ExternalDNS instances managing the same zone don’t fight over the same records.

IAM Permissions (AWS)

ExternalDNS needs permission to modify Route 53 records. A minimal IAM policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["route53:ChangeResourceRecordSets"],
      "Resource": ["arn:aws:route53:::hostedzone/*"]
    },
    {
      "Effect": "Allow",
      "Action": ["route53:ListHostedZones", "route53:ListResourceRecordSets"],
      "Resource": ["*"]
    }
  ]
}

If running on EKS, attach this via IRSA (IAM Roles for Service Accounts) rather than static credentials:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-dns
  namespace: external-dns
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/external-dns

Annotating Services and Ingresses

ExternalDNS picks up hostnames from annotations:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp
  namespace: production
  annotations:
    external-dns.alpha.kubernetes.io/hostname: app.example.com
    external-dns.alpha.kubernetes.io/ttl: "300"
spec:
  ingressClassName: nginx
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp
                port:
                  number: 80

For a LoadBalancer-type Service instead of Ingress:

apiVersion: v1
kind: Service
metadata:
  name: myapp
  namespace: production
  annotations:
    external-dns.alpha.kubernetes.io/hostname: api.example.com
spec:
  type: LoadBalancer
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 8080

Verifying Records Were Created

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

Output:

time="..." level=info msg="Desired change: CREATE app.example.com A"
time="..." level=info msg="Desired change: CREATE app.example.com TXT"
time="..." level=info msg="2 record(s) in zone example.com. were successfully updated"

Confirm from outside the cluster:

dig app.example.com +short

RBAC for ExternalDNS

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: external-dns
rules:
  - apiGroups: [""]
    resources: ["services", "endpoints", "pods"]
    verbs: ["get", "watch", "list"]
  - apiGroups: ["extensions", "networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get", "watch", "list"]
  - apiGroups: [""]
    resources: ["nodes"]
    verbs: ["list", "watch"]

ExternalDNS only needs read access inside the cluster — all the actual write access is against the external DNS provider’s API, authenticated separately (IAM, service account keys, or API tokens depending on provider).

Multi-Cluster and Multi-Provider Setups

Larger organizations often run ExternalDNS per-cluster with distinct txtOwnerId values and domainFilters scoped to the subdomains each cluster is allowed to manage — preventing a staging cluster’s ExternalDNS from accidentally touching production’s zone:

helm install external-dns external-dns/external-dns \
  --set txtOwnerId=staging-cluster \
  --set domainFilters={staging.example.com}

Monitoring and Troubleshooting

# Check sync errors
kubectl -n external-dns logs deploy/external-dns --tail=50

# Confirm the controller sees the right Ingress
kubectl -n external-dns exec deploy/external-dns -- external-dns --dry-run

A common issue: records not appearing because the Ingress controller hasn’t yet populated status.loadBalancer.ingress — ExternalDNS needs that field populated before it has an IP/hostname to point DNS at. Check:

kubectl get ingress myapp -n production -o jsonpath='{.status.loadBalancer}'

If that’s empty, the problem is upstream in the Ingress controller, not ExternalDNS itself.

High Availability Considerations

ExternalDNS is typically run as a single replica by design — running multiple active replicas against the same zone without careful leader election can cause conflicting writes. If HA is a concern, rely on Kubernetes’ own Deployment self-healing (a crashed Pod gets rescheduled) rather than horizontal replicas, and keep the sync interval short enough that a brief gap during a Pod restart doesn’t matter in practice.

How the TXT Registry Actually Prevents Conflicts

It’s worth understanding the ownership mechanism a bit more concretely, since it’s the part most likely to cause confusion during a multi-cluster rollout. For every A/CNAME record ExternalDNS creates, it also writes a companion TXT record encoding the txtOwnerId and a description of what created it:

dig TXT app.example.com +short
"heritage=external-dns,external-dns/owner=my-cluster,external-dns/resource=ingress/production/myapp"

Before touching any record, ExternalDNS checks whether a matching TXT record exists and, if so, whether its owner ID matches its own configuration. If another instance already owns a record, it’s skipped rather than overwritten — this is the entire mechanism that makes it safe to run ExternalDNS in more than one cluster against the same DNS zone, as long as each instance is configured with a distinct owner ID and, ideally, a non-overlapping domain filter as well.

Alternative Provider Example: Cloudflare

Not every environment runs on AWS. The Cloudflare setup looks structurally similar but authenticates differently, via an API token:

kubectl create secret generic cloudflare-api-token \
  --from-literal=api-token=$CF_API_TOKEN \
  --namespace external-dns

helm install external-dns external-dns/external-dns \
  --namespace external-dns \
  --set provider=cloudflare \
  --set env[0].name=CF_API_TOKEN \
  --set env[0].valueFrom.secretKeyRef.name=cloudflare-api-token \
  --set env[0].valueFrom.secretKeyRef.key=api-token \
  --set txtOwnerId=my-cluster \
  --set domainFilters={example.com}

The rest of the workflow — annotating Ingresses/Services, the reconciliation loop, the TXT ownership registry — behaves identically regardless of provider, which is one of ExternalDNS’s real strengths: switching DNS providers later doesn’t require rewriting how applications request DNS records, only reconfiguring the controller itself.

Using CRD Source Instead of Ingress/Service Annotations

For workloads that don’t naturally have an Ingress or Service — internal tooling, or records that don’t map to any running Kubernetes object at all — ExternalDNS also supports a dedicated DNSEndpoint CRD as a source:

apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
  name: custom-record
  namespace: external-dns
spec:
  endpoints:
    - dnsName: legacy-service.example.com
      recordTTL: 300
      recordType: A
      targets:
        - 203.0.113.10
helm upgrade external-dns external-dns/external-dns \
  --set sources={service,ingress,crd}

This is a useful escape hatch for the (fairly common) case of “we need ExternalDNS to manage one specific record that doesn’t correspond to any live Kubernetes workload,” such as a static third-party endpoint referenced by internal tooling.

Handling Multiple Record Types and Weighted Routing

Beyond simple A/CNAME records, ExternalDNS can manage more advanced routing policies for providers that support them — AWS Route 53’s weighted and latency-based routing, for instance, useful when a Service needs traffic split across regions rather than pointed at a single endpoint:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-us-east
  namespace: production
  annotations:
    external-dns.alpha.kubernetes.io/hostname: app.example.com
    external-dns.alpha.kubernetes.io/aws-weight: "70"
    external-dns.alpha.kubernetes.io/set-identifier: "us-east"
spec:
  ingressClassName: nginx
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp
                port:
                  number: 80

A second Ingress in a different cluster/region, annotated with aws-weight: "30" and a distinct set-identifier, completes a 70/30 traffic split between two regions — both records share the same hostname but route proportionally, entirely managed through Kubernetes annotations rather than manual Route 53 console configuration.

Webhook Providers for Unsupported Backends

Not every DNS provider has built-in ExternalDNS support. The webhook provider interface, introduced to decouple provider-specific code from the core ExternalDNS binary, lets a small HTTP service implement the provider contract independently:

helm install external-dns external-dns/external-dns \
  --set provider=webhook \
  --set extraArgs[0]="--webhook-provider-url=http://dns-provider-webhook:8888"

This has become the recommended path for newer or less common DNS providers, since it means provider-specific logic ships and versions independently of the main ExternalDNS release cycle — a provider bug fix no longer waits on the next full ExternalDNS release.

Dry-Run Mode Before Trusting Production Sync

Before enabling policy=sync (which allows deletions) against a zone that also has manually managed records outside ExternalDNS’s awareness, running in dry-run mode first is the safer default:

helm install external-dns external-dns/external-dns \
  --set provider=aws \
  --set dryRun=true \
  --set domainFilters={example.com}
kubectl -n external-dns logs deploy/external-dns
time="..." level=info msg="Would create A record app.example.com"

Reviewing a run of “would create/update/delete” log lines against expectations before flipping dryRun off is a cheap safeguard against ExternalDNS deleting a manually created record it doesn’t recognize as its own.

Common Mistakes

  • Forgetting domainFilters, which lets ExternalDNS touch every zone in the account rather than just the one the cluster owns.
  • Using policy=sync in a shared zone without a distinct txtOwnerId per cluster, causing clusters to delete each other’s records.
  • Not granting sufficient IAM/API permissions and only discovering it via silent failures in the logs.
  • Assuming ExternalDNS creates the load balancer — it doesn’t; that’s the Ingress controller’s or cloud provider’s job. ExternalDNS only manages DNS records pointing at whatever address already exists.

Summary

ExternalDNS removes the manual, error-prone step of keeping DNS in sync with a fast-moving cluster. Point it at a hosted zone, annotate Services and Ingresses with the desired hostname, and it handles create/update/delete automatically — including cleaning up stale records when something is torn down, which is the step humans forget most often.

References

  • ExternalDNS GitHub repository: https://github.com/kubernetes-sigs/external-dns
  • Provider-specific setup tutorials: https://github.com/kubernetes-sigs/external-dns/tree/master/docs/tutorials
  • Kubernetes Ingress documentation: https://kubernetes.io/docs/concepts/services-networking/ingress/
  • CNCF landscape: https://landscape.cncf.io/
Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Storage Classes in Kubernetes

How to Set Up Storage Classes in Kubernetes

Next Post
How to Set Up Kubernetes Monitoring with Grafana

How to Set Up Kubernetes Monitoring with Grafana

Related Posts