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 , 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.

ExternalDNS reads a source, filters it, and writes a record plus an ownership TXT recordAn Ingress or Service with a hostname flows through source and filter stages inside ExternalDNS. Its output is an A or CNAME record and a TXT record that names the owner ID and the source object. Both land in the same hosted zone.Ingress or Servicehostname annotationExternalDNS--source, --domain-filterA or CNAME recordapp.example.compoints at the load balancerOwnership TXT recordprefix + label, owner ID,source object (ingress/ns/name)The TXT record is what lets ExternalDNS know a record is its own. Lose or rename it and the controller stops recognising the record.
ExternalDNS turns a hostname annotation into an address record and a TXT ownership record. Both live in the same zone, and the TXT name is built from the prefix plus the label.

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

shell
# 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:

text
# 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"
Illustrative, not captured from a live cluster. The second line is the one to act on: the role lacks ChangeResourceRecordSets.

2. Confirm the source carries the hostname

shell
# 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

shell
# 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.com

If 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.

yaml
args:
  - --source=ingress
  - --source=service
  - --domain-filter=example.com
  - --policy=sync
  - --registry=txt
  - --txt-owner-id=prod-eu-west-1
  - --txt-prefix=ext-
A complete argument list for one shared zone. Each line is one argument, and the owner ID is fixed for the life of the deployment.

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.

json
{
  "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": "*"
    }
  ]
}
A minimal policy. Scope ChangeResourceRecordSets to the zone ID. The list actions read zones and records, and need the wildcard resource.
shell
# 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-route53

An 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.

yaml
# Wrong: the quotes are plain characters in the YAML scalar
args:
  - --txt-owner-id=prod-eu-west-1
  - --txt-prefix="ext-"
Before. The quotes sit inside the scalar, so the value is the quote character plus ext- plus another quote character.
yaml
# Right: the whole argument is quoted, so the value has no quotes
args:
  - --txt-owner-id=prod-eu-west-1
  - "--txt-prefix=ext-"
After. The whole argument is quoted, so the process receives --txt-prefix=ext- with no quote characters in the value.
Quote characters inside a YAML scalar become part of the TXT prefixTwo rows. The top row shows an args entry where the quotes are plain characters, so the controller looks for a TXT name starting with a quote. The bottom row shows the intended value, where the controller looks for a TXT name starting with ext-. The DNS record is the same in both rows, so the lookup misses and the record is treated as unowned.Written as: - --txt-prefix="ext-"controller prefix = "ext-"TXT looked up: "ext-"app.example.comWritten as: - "--txt-prefix=ext-"controller prefix = ext-TXT looked up: ext-app.example.comThe record exists in both rows. Only the TXT name differs, and under upsert-only the controller leaves an unowned record alone.
The same record, two different TXT lookups. The quote characters change the name the controller searches for, so the owner entry is never found.

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.

Frequently asked questions

ExternalDNS not creating records: FAQ

Why does ExternalDNS log nothing and still create no record?

Usually the object never reached the controller. Check that the Service or Ingress carries the hostname annotation, that its class or namespace matches the flags, and that the domain sits under a --domain-filter. A silent controller often means a filter excluded the object before any DNS call was made.

What is the ExternalDNS TXT record for?

It records ownership. For every record ExternalDNS creates, it writes a TXT record with the owner ID and the source object. On the next pass, the controller only changes records whose TXT entry names its own owner ID. Without the TXT record, the controller treats the name as not its own.

Should I use --policy=sync or upsert-only?

Use sync when ExternalDNS is the only writer for a zone and you want it to delete records when the source goes away. Use upsert-only in shared zones, where other tools also write records, because upsert-only creates and updates but never deletes. The ExternalDNS documentation states this directly in its AWS tutorial.

Can I change --txt-prefix after the first deploy?

Not safely. The documentation says the prefix or suffix may not change after the initial deployment, because the existing registry records would be orphaned and their metadata lost. Pick the prefix before the first sync, and treat it as a permanent setting.

What IAM permissions does ExternalDNS need for Route 53?

It needs route53:ChangeResourceRecordSets on the hosted zone, plus list permissions to read the zones and records: route53:ListHostedZones, route53:ListResourceRecordSets and, when you filter by zone tags, route53:ListTagsForResource. Grant them to the ExternalDNS service account through IRSA or EKS Pod Identity, and check the role with a test change.

Why does a TXT record exist but no A record appears?

Check the source first. ExternalDNS writes the A or CNAME record from the source host, so a TXT record for that name with no matching address record points to a controller that found the owner entry but could not complete the write. Read the controller logs for an AccessDenied or InvalidChangeBatch error at the same timestamp.

Sources

Checked 10 October 2026.

  1. The TXT registry (ExternalDNS documentation) — ownership TXT records, --txt-prefix and --txt-suffix rules, the 63-byte limit, and encryption flags
  2. Registries (ExternalDNS documentation) — why --txt-owner-id must be unique per deployment in a shared zone
  3. Flags (ExternalDNS documentation) — source, filter, policy and registry flags as the project lists them
  4. AWS tutorial (ExternalDNS documentation) — zone filters, the upsert-only behaviour and the manual cleanup note
  5. Annotations (ExternalDNS documentation) — the annotation prefix and how ExternalDNS reads annotations from Ingress and Service objects

Read next