Troubleshooting
ExternalDNS Not Creating Records: Route 53 Fixes
You added a Service or Ingress with a hostname, and the record never appears in Route 53. ExternalDNS runs, logs look quiet, and the name stays empty. The cause is usually one of five things: a filter that drops the object, a policy that refuses the write, a TXT ownership record that does not match, an IAM gap, or a quoting mistake in the controller arguments. This guide walks through each one in the order that finds the fault fastest.
By Muhammad Soliman, Founder at SeaGit
·
Key takeaways
- Start with the controller arguments and logs. Most “no records” cases show an error or a filter in the first two commands.
- ExternalDNS only changes records it can prove it owns. Each record needs a matching TXT ownership entry with the same owner ID.
- A --txt-prefix is glued to the first label of the name. Quote characters inside a YAML list item become part of the value.
- Under --policy=upsert-only the controller creates and updates but never deletes. Pick the policy for the zone, not for the cluster.
- Give the ExternalDNS role the Route 53 change and list actions for the hosted zone, through IRSA or EKS Pod Identity, and test with one record.
Why does ExternalDNS not create my records?
ExternalDNS creates a DNS record only when four checks pass. The source object must be read, it must carry a hostname, the hostname must fall inside a zone the controller is allowed to edit, and the provider call must succeed. A failure at any step looks the same from the outside, which is why the checklist below goes in order.
What “not creating records” usually means
People use the phrase for three different symptoms, and each one points at a different layer. The first is no record at all, with no TXT either. That usually means the source was never picked up. The second is a TXT record with no address record next to it. That means the ownership write worked and the address write failed. The third is a record that exists but never changes when the load balancer changes. That is usually a policy or owner-ID problem, not a creation problem.
Work out which symptom you have before you change any flag. Each fix below names the symptom it addresses.
How ExternalDNS decides a record is its own
ExternalDNS is stateless about its own records. It stores the ownership facts in the same DNS provider, using a TXT registry. The TXT registry is the default. For each record it creates, the controller writes a TXT record whose content names the owner ID and the source object. The registry documentation makes the owner ID the most important piece of metadata. It must stay the same for the life of the deployment, and deployments in different clusters that share a zone must use different owner IDs.
On each pass, the controller reads the TXT records and decides which address records it may change. A record with no matching TXT entry is treated as not its own. That is the safe default, and it is also why a renamed prefix or a changed owner ID makes records appear to vanish or freeze.
Diagnosis checklist
Run these in order and stop at the first step that shows a fault. Each step needs read access only. The controller logs can be noisy, so raise the verbosity only if the default output is not enough.
1. Check the arguments and the logs
# Which arguments did the running controller receive?
kubectl -n external-dns get deploy external-dns \
-o jsonpath='{.spec.template.spec.containers[0].args}'
# Errors, denied calls and skipped objects from the last hour
kubectl -n external-dns logs deploy/external-dns --since=1h \
| grep -iE 'error|denied|skip|owner'The arguments tell you the sources, filters, policy and owner ID the process really has. A missing --source line or a typo in the owner ID explains most silent cases. Quote-related faults also show up here, as covered in the pitfall section below.
An error such as AccessDenied or an InvalidChangeBatch names the failing call. Illustrative output for that kind of failure looks like this:
# Illustrative output, not captured from a live cluster
time="2026-10-10T09:14:02Z" level=info msg="Desired change: CREATE app.example.com A [Id: /hostedzone/Z0123456789EXAMPLE]"
time="2026-10-10T09:14:02Z" level=error msg="Failure in zone example.com. [Id: Z0123456789EXAMPLE]: AccessDenied: User is not authorized to perform route53:ChangeResourceRecordSets"2. Confirm the source carries the hostname
# Which Ingress and Service objects carry the hostname annotation?
kubectl get ingress,svc -A \
-o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,HOST:.metadata.annotations.external-dns\.kubernetes\.io/hostname'ExternalDNS reads hostnames from annotations with the prefix external-dns.kubernetes.io/. The legacy prefix is off by default. If your objects use an older annotation, the flag --enable-legacy-annotation-prefix exists, but moving the annotation is the cleaner fix.
3. Compare Route 53 with what the controller should write
# Find the hosted zone that should hold the record
aws route53 list-hosted-zones-by-name --dns-name example.com \
--query 'HostedZones[].Id'
# What the zone actually contains for that name
aws route53 list-resource-record-sets \
--hosted-zone-id Z0123456789EXAMPLE \
--query "ResourceRecordSets[?contains(Name, 'app.example.com')]"
# What the public internet sees
dig +short app.example.com
dig +short TXT ext-app.example.comIf the zone shows the TXT record and not the address record, the write was refused. If neither exists, look at the filters. If both exist but the answer is stale, check the owner ID on the TXT record. The dig lines confirm what resolvers see, which can differ from the zone during a TTL window.
Policy: sync, upsert-only, create-only
The --policy flag controls which changes the controller may make. It is the most common reason a record refuses to update, and the least obvious one.
- sync creates, updates and deletes records it owns. Use it when ExternalDNS is the only writer for the zone.
- upsert-only creates and updates but never deletes. The AWS tutorial says so directly, and tells operators to remove stale records by hand. It is the safe choice in a zone that other tools also write.
- create-only only creates. It will not change a record that already exists, so a new load balancer address never reaches an old name.
The symptom of a policy problem is a record that exists and stops following the source. Check the policy in the arguments first. If it is upsert-only and you expected deletes, the controller is doing what it was told. Changing that is a decision about the zone, covered in the fixes section.
Sources and zone filters
Filters quietly remove objects before any DNS call is made. The documentation lists the ones that matter most. Limit the sources with --source (for example ingress and service), and narrow the objects with --namespace, --label-filter, --ingress-class or --annotation-filter. Limit the zones with --domain-filter, --zone-id-filter, --exclude-domains and, for Route 53, --aws-zone-type and --aws-zone-tags.
A hostname outside every filtered zone is skipped with no error. A common case is a public zone and a private zone with the same name, where the filter selects only one of them. Check that --aws-zone-type matches the zone the clients use.
args:
- --source=ingress
- --source=service
- --domain-filter=example.com
- --policy=sync
- --registry=txt
- --txt-owner-id=prod-eu-west-1
- --txt-prefix=ext-Route 53 permissions with IRSA or Pod Identity
ExternalDNS needs to change records in the hosted zone and read the zones and their records. On EKS, the usual way to grant that is an IAM role bound to the controller's service account. IRSA does it with an annotation and a trust policy. EKS Pod Identity does it with an association, which needs no OIDC trust policy and is simpler to manage.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ChangeRecordsInOneZone",
"Effect": "Allow",
"Action": "route53:ChangeResourceRecordSets",
"Resource": "arn:aws:route53:::hostedzone/Z0123456789EXAMPLE"
},
{
"Sid": "ReadZonesAndRecords",
"Effect": "Allow",
"Action": [
"route53:ListHostedZones",
"route53:ListResourceRecordSets",
"route53:ListTagsForResource"
],
"Resource": "*"
}
]
}# Bind the ExternalDNS service account to the role with EKS Pod Identity
aws eks create-pod-identity-association \
--cluster-name my-cluster \
--namespace external-dns \
--service-account external-dns \
--role-arn arn:aws:iam::111122223333:role/external-dns-route53An AccessDenied in the logs at the same moment as a failed write is the signature of a missing action. Add the action, restart the controller, and check again with one new Service. Do not widen the policy to all zones until the single zone works.
The TXT prefix quoting pitfall
This is the fault we hit most often when wiring ExternalDNS through templates. The prefix works in a shell and fails in a YAML list, because YAML does not remove the quotes on a plain scalar. The flag is written as --txt-prefix="ext-", which the process reads as an argument whose value starts and ends with a quote character.
The controller then names its ownership TXT records with those quote characters in the prefix. Anything else that writes the records, such as a second tool that uses the bare prefix, produces names that never match. The record exists, the owner entry sits under a different name, and under upsert-only the controller leaves the record alone. Nothing is logged as a crash.
# Wrong: the quotes are plain characters in the YAML scalar
args:
- --txt-owner-id=prod-eu-west-1
- --txt-prefix="ext-"# Right: the whole argument is quoted, so the value has no quotes
args:
- --txt-owner-id=prod-eu-west-1
- "--txt-prefix=ext-"The same fault appears in Helm values and Terraform templates, where a value rendered into a YAML string keeps its quotes. Render the final manifest and read the argument as the process receives it, using the first command in the checklist.
Fixes and prevention
Match the fix to the symptom from the start of this guide.
- No record and no TXT. Fix the source or the filter. Add the hostname annotation, correct the namespace or class flag, and confirm the zone matches
--domain-filter. - TXT present, address missing. Fix the permission. Add the Route 53 change action for the zone, through IRSA or Pod Identity, and retest with one object.
- Record present, owner mismatch. Remove the quotes from the prefix argument, or align the prefix across every writer. Do not change the prefix on a running deployment, because the existing registry records would be orphaned.
- Record frozen after a change. Check the policy. Under create-only the record never updates. Under upsert-only it updates but never deletes.
Prevention is mostly naming and ownership. Give each deployment of ExternalDNS a unique, stable owner ID. Keep the prefix in one place and make the quoting rule part of the template review, with a test that renders the manifest and checks each argument as its own string. Use --policy=sync only in zones that one controller owns. Keep the TXT prefix short, because it counts against the 63-byte limit on record names.
If you need the TXT contents hidden, the registry has encryption flags: --txt-encrypt-enabled and --txt-encrypt-aes-key. The documentation calls encryption best-effort. Stored values depend on the Go toolchain, so test updates and deletes on a non-production zone before you turn it on.
How SeaGit handles this
SeaGit runs ExternalDNS with --policy=upsert-only, as the design notes for its DNS reaper state. The upsert-only policy means the controller never deletes records, so the platform has a separate reaper. On the release branch, the reaper deletes only the records that the TXT registry proves belong to the controller that wrote them. It works on the same registry format described above, and the decision logic is provider-agnostic.
That reaper addresses the cleanup side of the problem. The creation side is the checklist in this guide. The DNS documentation covers how SeaGit wires zones and domains to clusters, and the Route 53 application guide shows the setup for an application.