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:
- Lists Services/Ingresses with relevant annotations.
- Compares desired records against the actual state in the DNS provider (via provider API).
- Reconciles differences — creates missing records, updates changed ones, deletes orphaned ones (if
--policy=syncis 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=syncin a shared zone without a distincttxtOwnerIdper 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/