Search across all documentation pages
--policy=sync can delete records it did not create.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-clusterdomainFilters prevents the controller from touching zones outside your scope.policy=upsert-only never deletes records, which is the safe default for a first rollout.txtOwnerId must be unique per cluster, or two clusters will fight over the same records.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: 8443status.loadBalancer.ingress to be populated before writing anything.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=50The --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.
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,..."
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.
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-dnsroute53:ChangeResourceRecordSets on the specific hosted zone, plus route53:ListHostedZones and route53:ListResourceRecordSets.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.
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.
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.
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.
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).
Reviewed by Chris St. John·Last updated Jul 16, 2026