external-dns
Summary
- Definition: external-dns is a controller that reads Kubernetes Services, Ingresses, and Gateway API routes and publishes matching records into a DNS provider such as Route53, Cloud DNS, or Cloudflare.
- Insight: it turns DNS into a derived artifact of cluster state, so the hostname on an Ingress is the single source of truth instead of a ticket to the network team.
- Key Concepts: sources (what it reads), providers (where it writes), registry (ownership TXT records), policy (sync vs upsert-only), and domain filters.
- When to Use: whenever load balancer addresses are dynamic, teams self-serve hostnames, or you rebuild clusters and need DNS to follow.
- Limitations: it is a one-way sync, it needs write credentials to your zone, and a misconfigured
--policy=synccan delete records it did not create.
Recipe
Grant cloud DNS permissions, install the controller, and scope it to a zone.
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm upgrade --install external-dns external-dns/external-dns \
--namespace external-dns --create-namespace \
--set provider.name=aws \
--set 'domainFilters={example.com}' \
--set policy=upsert-only \
--set txtOwnerId=prod-clusterdomainFiltersprevents the controller from touching zones outside your scope.policy=upsert-onlynever deletes records, which is the safe default for a first rollout.txtOwnerIdmust be unique per cluster, or two clusters will fight over the same records.
Working Example
Annotate a Service of type LoadBalancer and external-dns creates the record pointing at the provisioned address.
apiVersion: v1
kind: Service
metadata:
name: checkout
namespace: prod
annotations:
external-dns.alpha.kubernetes.io/hostname: checkout.example.com
external-dns.alpha.kubernetes.io/ttl: "60"
spec:
type: LoadBalancer
selector:
app: checkout
ports:
- port: 443
targetPort: 8443- The controller waits for
status.loadBalancer.ingressto be populated before writing anything. - A hostname-based LB (AWS ELB) yields a CNAME or ALIAS; an IP-based LB yields an A record.
- The TTL annotation is honored per record and defaults to a provider-specific value.
For Ingress you usually need no annotation at all, since the host is already declared.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkout
namespace: prod
spec:
ingressClassName: nginx
rules:
- host: checkout.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: checkout
port:
number: 80external-dns reads spec.rules[].host, resolves the Ingress status address, and publishes the record.
Verify the reconciliation from logs.
kubectl -n external-dns logs deploy/external-dns --tail=50Deep Dive
Sources
The --source flag selects what the controller watches, and you can pass it multiple times.
Common sources are service, ingress, gateway-httproute, crd, and istio-virtualservice.
Enabling only what you use keeps the controller's watch cache small and its permissions story simple.
The TXT registry
external-dns does not remember what it created; it writes an ownership TXT record alongside every managed record.
That TXT contains a heritage string including your txtOwnerId, and the controller refuses to modify records whose TXT it does not own.
This is why two clusters sharing a zone are safe as long as their owner IDs differ, and dangerous the moment they collide.
checkout.example.com A 203.0.113.10
cname-checkout.example.com TXT "heritage=external-dns,external-dns/owner=prod-cluster,..."
- Deleting the TXT record orphans the A record permanently, since the controller no longer claims it.
- Pre-existing records you created by hand have no TXT, so external-dns leaves them alone.
Policies
upsert-only creates and updates but never deletes, so stale records accumulate after a Service is removed.
sync creates, updates, and deletes, which keeps the zone clean but means a broken selector can wipe records.
Start with upsert-only, confirm the records look right for a week, then move to sync.
Credentials
On EKS, bind the service account to an IAM role via IRSA rather than mounting static keys.
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
namespace: external-dns
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/external-dns- The role needs
route53:ChangeResourceRecordSetson the specific hosted zone, plusroute53:ListHostedZonesandroute53:ListResourceRecordSets. - Scope the change permission to one zone ARN; a wildcard here is a real blast-radius problem.
- GKE uses Workload Identity, and AKS uses Workload Identity federation, with the same principle.
Gateway API
With Gateway API, the record comes from the HTTPRoute hostnames and the parent Gateway's status address.
Enable --source=gateway-httproute and give the controller RBAC on gateway.networking.k8s.io resources.
This pairs naturally with cert-manager's gateway shim, so one hostname drives both DNS and TLS.
Gotchas
Owner ID collisions across clusters. Two clusters with txtOwnerId=default sharing a zone will flap the same record back and forth. Always name it after the cluster.
sync policy plus a narrow domain filter. If the filter accidentally matches your apex zone, sync will remove every record that lacks a TXT owner marker in that zone. Test the filter with --dry-run first.
No LoadBalancer address, no record. A pending Service or an Ingress whose controller never sets status produces silent no-ops. Check kubectl get ingress for an address before debugging DNS.
Provider rate limits. Route53 throttles ChangeResourceRecordSets, and a cluster churning hundreds of Ingresses can back off for minutes. Raise --interval rather than fighting the API.
Split-horizon confusion. Publishing internal Services to a public zone leaks internal topology. Run a second external-dns instance scoped to a private zone with its own owner ID.
Annotation typos fail silently. external-dns.alpha.kubernetes.io/hostname is exact, and a misspelling means no record and no error. Grep the controller logs for the hostname to confirm it was seen.
Alternatives
Manual DNS in Terraform keeps records in the same IaC as the zone itself. It is explicit and reviewable, but it lags cluster changes and turns every new hostname into a plan-and-apply cycle.
Cloud-native controllers like the AWS Load Balancer Controller can manage some records for you. They are simpler when you use exactly one cloud primitive, but they do not generalize across sources.
Wildcard records (*.example.com pointing at your ingress LB) remove the need for per-service DNS entirely. This is the least machinery, but it forfeits per-service TTLs, split-horizon, and any record-level audit trail.
Service mesh ingress with internal DNS covers east-west traffic without touching the public zone. It solves in-cluster discovery, not the external hostname problem external-dns exists for.
FAQs
Does external-dns create the hosted zone? No. It only writes records into zones that already exist, and it needs the zone to match a domain filter.
Can it manage records for a domain hosted at a different provider? Yes, run one instance per provider with disjoint domain filters, each with its own credentials and owner ID.
What happens when I delete a Service under upsert-only?
Nothing. The record and its TXT stay behind until you delete them manually or switch to sync.
How do I migrate records created by hand?
Create the matching TXT ownership record yourself, or let external-dns adopt them by enabling sync after verifying with --dry-run.
Why are there two TXT records per name?
Newer versions write both a prefixed TXT and a type-qualified one (a- or cname- prefixed) to disambiguate record types sharing a name.
Does it support weighted or latency routing? Route53-specific annotations exist for set identifiers and weights, but complex routing policies are usually better expressed in Terraform.
Related
- Platform Tools Basics
- The Platform Engineer's Toolkit, Conceptually
- cert-manager
- Velero
- Platform Tools Best Practices
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).