65.3 ExternalDNS with Route 53: DNS records that follow your Ingress
Why: the problem
In 65.2 we created DNS records by hand. That works for one site, but Razorpay runs hundreds of services across several clusters, and every new Ingress, every recreated load balancer and every deleted app means a DNS change. Humans forget. A forgotten record is either a broken site (record points at a deleted load balancer) or a security hole (a dangling record that someone else can take over by creating a resource with that address). ExternalDNS is a small controller that runs inside the cluster. It watches Ingress, Service and Gateway API route objects, reads their host names and the load balancer address in their status, and creates matching records in your DNS provider: Route 53, Cloudflare, Google Cloud DNS, Azure DNS and many more. When you delete the Ingress, it deletes the record. It never touches records it did not create, because it writes an ownership TXT record next to each one, stamped with a txt-owner-id you choose, such as the cluster name. Safety settings matter. --domain-filter limits ExternalDNS to one zone, so a typo cannot create records in your main company domain. --policy=upsert-only lets it create and update but never delete, which is a common first step in production; --policy=sync also deletes, which keeps the zone clean. --registry=txt with a unique --txt-owner-id lets two clusters share one zone without fighting. On AWS, ExternalDNS needs IAM permission to change records in that one hosted zone, given through IRSA (IAM roles for service accounts) or EKS Pod Identity, never through access keys in a Secret. In this lesson we see ExternalDNS work with no cloud account by using its in-memory provider on kind, then write the real Route 53 setup for EKS: a scoped IAM policy, the service account annotation and the Deployment flags, and finally see records appear and disappear in Route 53 as Ingress objects come and go.
What: every term in plain English
- ExternalDNS — Controller that syncs DNS records from Kubernetes objects to a DNS provider.
- Source — The object types ExternalDNS reads, such as ingress, service and gateway-httproute.
- Provider — The DNS service it writes to, such as aws for Route 53.
- Hosted zone — A Route 53 container for one domain's records, with a zone ID.
- --domain-filter — Restricts changes to names under one domain.
- --policy — sync creates, updates and deletes; upsert-only never deletes.
- TXT registry — Ownership records that mark which records this ExternalDNS instance manages.
- txt-owner-id — Unique name per cluster written into the ownership records.
- IRSA — IAM roles for service accounts: a pod gets AWS permissions through a signed service account token.
- Dangling record — A DNS record still pointing at a deleted resource; a takeover risk.
How: step by step
- Run ExternalDNS with the in-memory provider on kind. Logs show planned records.
- Create an Ingress with a host. ExternalDNS plans an A record.
- Delete the Ingress. Record is removed with sync policy.
- Write the Route 53 IAM policy for one zone. Least privilege.
- Create the IRSA service account. Role attached.
- Deploy ExternalDNS for aws. Pod Running.
- Create an Ingress on EKS. Record appears in Route 53.
- Check TXT ownership records. Owner id visible.
Example 1: See ExternalDNS work on kind with the in-memory provider
Goal. See ExternalDNS work on kind with the in-memory provider.
external-dns-inmemory.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: external-dns
rules:
- apiGroups: [""]
resources: ["services", "endpoints", "pods", "nodes"]
verbs: ["get", "watch", "list"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: external-dns
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: external-dns}
subjects:
- {kind: ServiceAccount, name: external-dns, namespace: kube-system}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
namespace: kube-system
spec:
replicas: 1
selector: {matchLabels: {app: external-dns}}
template:
metadata: {labels: {app: external-dns}}
spec:
serviceAccountName: external-dns
containers:
- name: external-dns
image: registry.k8s.io/external-dns/external-dns:v0.19.0
args:
- --source=ingress
- --provider=inmemory
- --inmemory-zone=example.com
- --domain-filter=example.com
- --policy=sync
- --registry=txt
- --txt-owner-id=ch65-kind
- --interval=20s
What each line does
RBAC— ExternalDNS only reads Kubernetes objects, so get, watch and list are enough.--source=ingress— It reads hosts from Ingress rules and the address from Ingress status.--provider=inmemory— A fake zone in memory, perfect to learn the behaviour with no cloud account.--txt-owner-id— Marks records as owned by this cluster.--interval=20s— How often it compares desired and actual records.
Green output
$ kubectl apply -f external-dns-inmemory.yaml
deployment.apps/external-dns created
$ kubectl get ingress storefront -n shop
NAME CLASS HOSTS ADDRESS
storefront nginx shop.example.com,www.shop.example.com localhost
$ kubectl logs -n kube-system deploy/external-dns --tail=4
level=info msg="CREATE: shop.example.com 0 IN CNAME localhost []"
level=info msg="CREATE: www.shop.example.com 0 IN CNAME localhost []"
level=info msg="CREATE: a-shop.example.com 0 IN TXT \"heritage=external-dns,external-dns/owner=ch65-kind\""
$ kubectl delete ingress storefront -n shop
$ kubectl logs -n kube-system deploy/external-dns --tail=2
level=info msg="DELETE: shop.example.com 0 IN CNAME localhost []"
level=info msg="DELETE: www.shop.example.com 0 IN CNAME localhost []"
Expected result. ExternalDNS read the Ingress hosts, created a record per host plus an ownership TXT record, and removed them when the Ingress was deleted. Because the address is a name (localhost), it chose CNAME; on AWS with an alias-capable target it writes alias A records.
Example 2: Give ExternalDNS least-privilege Route 53 access with IRSA
Goal. Give ExternalDNS least-privilege Route 53 access with IRSA.
external-dns-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["route53:ChangeResourceRecordSets"],
"Resource": ["arn:aws:route53:::hostedzone/Z0123456789EXAMPLE"]
},
{
"Effect": "Allow",
"Action": ["route53:ListHostedZones", "route53:ListResourceRecordSets", "route53:ListTagsForResources"],
"Resource": ["*"]
}
]
}
irsa.sh
aws iam create-policy --policy-name ExternalDNSShopZone --policy-document file://external-dns-policy.json
eksctl create iamserviceaccount \
--cluster shop-prod --namespace kube-system --name external-dns \
--attach-policy-arn arn:aws:iam::111122223333:policy/ExternalDNSShopZone \
--role-name shop-prod-external-dns --approve
kubectl get sa external-dns -n kube-system -o jsonpath='{.metadata.annotations}'
What each line does
One zone ARN— Change rights only on the shop zone, not every zone in the account.List actions on *— Route 53 list calls cannot be scoped to one zone, and they are read-only.111122223333— AWS's documentation placeholder account ID; use yours.eksctl create iamserviceaccount— Creates the IAM role with a trust policy for this one service account and annotates it.No access keys— The pod gets short-lived credentials from its service account token.
Green output
$ bash irsa.sh
{
"Policy": {
"PolicyName": "ExternalDNSShopZone",
"Arn": "arn:aws:iam::111122223333:policy/ExternalDNSShopZone"
}
}
created serviceaccount "kube-system/external-dns"
{"eks.amazonaws.com/role-arn":"arn:aws:iam::111122223333:role/shop-prod-external-dns"}
Expected result. The service account now carries a role ARN. Any pod using it gets temporary credentials that can change only the shop zone, which is how Razorpay-style platform teams keep DNS automation from touching the main company domain.
Example 3: Run ExternalDNS for Route 53 and watch records follow an Ingress
Goal. Run ExternalDNS for Route 53 and watch records follow an Ingress.
external-dns-aws-args.yaml
args:
- --source=ingress
- --source=service
- --provider=aws
- --aws-zone-type=public
- --domain-filter=shop.example.com
- --policy=upsert-only
- --registry=txt
- --txt-owner-id=shop-prod
- --txt-prefix=edns-
- --interval=1m
check.sh
kubectl rollout status deploy/external-dns -n kube-system
kubectl logs -n kube-system deploy/external-dns --tail=5
aws route53 list-resource-record-sets --hosted-zone-id Z0123456789EXAMPLE \
--query "ResourceRecordSets[?contains(Name,'shop')].[Name,Type,AliasTarget.DNSName]" --output table
What each line does
--provider=aws— Writes to Route 53 using the IRSA credentials.--domain-filter=shop.example.com— Even with rights to the zone, it ignores names outside this suffix.--policy=upsert-only— Start safe: create and update only. Switch to sync after you trust it.--txt-prefix=edns-— Puts ownership TXT records under edns-name, so they never clash with a CNAME on the same name.--source=service— LoadBalancer Services with the external-dns.alpha.kubernetes.io/hostname annotation also get records.
Green output
$ bash check.sh
deployment "external-dns" successfully rolled out
level=info msg="Applying provider record filter for domains: [shop.example.com. .shop.example.com.]"
level=info msg="Desired change: CREATE shop.example.com A [Id: /hostedzone/Z0123456789EXAMPLE]"
level=info msg="Desired change: CREATE edns-a-shop.example.com TXT [Id: /hostedzone/Z0123456789EXAMPLE]"
level=info msg="2 record(s) in zone shop.example.com. [Id: /hostedzone/Z0123456789EXAMPLE] were successfully updated"
-------------------------------------------------------------------------------------------------
| ListResourceRecordSets |
+----------------------------+-----+------------------------------------------------------------+
| shop.example.com. | A | k8s-ingressn-ingressn-1a2b3c4d5e-0123456789.elb.ap-south-1.amazonaws.com. |
| edns-a-shop.example.com. | TXT | None |
+----------------------------+-----+------------------------------------------------------------+
Expected result. Within one interval an alias A record pointing at the controller's NLB appeared in Route 53, with a TXT ownership record beside it. No ticket, no console click, and the record will follow the load balancer if it is ever recreated.
Every command, step by step (kind lab with kubectl)
-
In-memory
kubectl apply -f external-dns-inmemory.yamlWhat it does: Fake zone.
deployment.apps/external-dns created -
Logs
kubectl logs -n kube-system deploy/external-dns --tail=4What it does: Planned records.
CREATE: shop.example.com 0 IN CNAME localhost -
Delete
kubectl delete ingress storefront -n shopWhat it does: Sync removes.
DELETE: shop.example.com -
Restore
kubectl apply -f hosts.yamlWhat it does: Back.
ingress.networking.k8s.io/storefront created -
Policy
aws iam create-policy --policy-name ExternalDNSShopZone --policy-document file://external-dns-policy.jsonWhat it does: Scoped.
"PolicyName": "ExternalDNSShopZone" -
IRSA
eksctl create iamserviceaccount --cluster shop-prod --namespace kube-system --name external-dns ...What it does: Role.
created serviceaccount "kube-system/external-dns" -
Annotation
kubectl get sa external-dns -n kube-system -o yamlWhat it does: Role ARN.
eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/shop-prod-external-dns -
Rollout
kubectl rollout status deploy/external-dns -n kube-systemWhat it does: Running.
successfully rolled out -
Records
aws route53 list-resource-record-sets --hosted-zone-id Z0123456789EXAMPLEWhat it does: Alias A.
shop.example.com. A -
Lookup
dig +short shop.example.comWhat it does: Load balancer IPs.
13.235.10.21
When it is red 1
AccessDenied: not authorized to perform: route53:ChangeResourceRecordSets
Cause. Policy scoped to a different zone ID or IRSA not attached.
Fix, step by step
- Check the zone ID in the policy.
- kubectl get sa external-dns -o yaml for the role annotation.
When it is red 2
No records created and logs say 'All records are already up to date'
Cause. Ingress has no address in status yet, or the host is outside --domain-filter.
Fix, step by step
- kubectl get ingress; wait for ADDRESS.
- Check the filter.
When it is red 3
Skipping record because it is not owned by this instance
Cause. A record exists with another owner id or was made by hand.
Fix, step by step
- Delete the manual record once, or use a different name.
When it is red 4
Two clusters keep overwriting the same record
Cause. Both use the same txt-owner-id.
Fix, step by step
- Give each cluster a unique --txt-owner-id.
When it is red 5
Records never deleted
Cause. Policy is upsert-only.
Fix, step by step
- Expected; switch to --policy=sync when ready.
Practice tasks
Easy Task 1: Deploy
Why: In-memory.
What to do: Apply external-dns-inmemory.yaml.
- kind cluster.
Solution
kubectl get deploy -n kube-system external-dnsEasy Task 2: Create
Why: Watch.
What to do: Create an Ingress and read the logs.
- CREATE lines.
Solution
kubectl logs -n kube-system deploy/external-dnsEasy Task 3: Delete
Why: Watch.
What to do: Delete the Ingress and read the logs.
- sync policy.
Solution
kubectl logs -n kube-system deploy/external-dns --tail=3Medium Task 4: Policy
Why: Scope.
What to do: Write a policy for one zone.
- ChangeResourceRecordSets.
Solution
cat external-dns-policy.jsonMedium Task 5: IRSA
Why: Attach.
What to do: Create the service account with eksctl.
- Role ARN.
Solution
kubectl get sa external-dns -n kube-system -o yamlMedium Task 6: Args
Why: Safe.
What to do: Write aws args with upsert-only.
- domain-filter.
Solution
kubectl get deploy external-dns -n kube-system -o yamlHard Task 7: Two clusters
Why: Share.
What to do: Explain two owner ids in one zone.
- txt-owner-id.
Solution
aws route53 list-resource-record-sets --hosted-zone-id Z0123456789EXAMPLEHard Task 8: Razorpay
Why: Main task.
What to do: In-memory demo plus AWS setup.
- No access keys.
Solution
kubectl logs -n kube-system deploy/external-dnsHomework: try at home
For Razorpay's checkout team, run ExternalDNS with the in-memory provider on kind for pay.example.com, show create and delete in logs, then write the Route 53 IAM policy scoped to one zone, the IRSA command and the ExternalDNS args with upsert-only and a unique owner id.
Recap
- ExternalDNS turns Ingress and Service host names into DNS records automatically.
- TXT ownership records and txt-owner-id keep it away from records it did not make.
- domain-filter and upsert-only are the safe starting settings.
- On EKS give it a scoped IAM role through IRSA or Pod Identity, never keys.
- Deleting an Ingress removes its record with sync policy, avoiding dangling records.
FAQ
Q. Can ExternalDNS work with Cloudflare instead of Route 53?
Yes. Set --provider=cloudflare and give it a scoped API token from a Kubernetes Secret created outside Git, or from a secrets manager.
Q. Does it support Gateway API?
Yes, with sources such as gateway-httproute; it reads hostnames from routes and addresses from the Gateway.
Q. How do I set TTL?
Add the annotation external-dns.alpha.kubernetes.io/ttl: "60" on the Ingress or Service.
Q. Is EKS Pod Identity better than IRSA?
Both give short-lived credentials. Pod Identity is simpler to set up on new EKS clusters; IRSA works everywhere including self-managed clusters.
Interview questions
-
What problem does ExternalDNS solve?
Model answer: Keeping DNS in sync with cluster resources automatically, preventing broken and dangling records.
-
How do two clusters share one hosted zone safely?
Model answer: Each runs ExternalDNS with a different txt-owner-id and often a different domain-filter.
-
What IAM rights does it need?
Model answer: ChangeResourceRecordSets on the target zone and read-only list calls.
-
Why start with upsert-only?
Model answer: So a mistake cannot delete production records while you gain confidence.
Quick quiz
Q1. What does ExternalDNS read?
Hosts and addresses from Ingress, Service and route objects.
Q2. How does it avoid touching other records?
TXT ownership records with txt-owner-id.
Q3. Which policy never deletes?
upsert-only.
Q4. How does it get AWS rights on EKS?
IRSA or Pod Identity.
Q5. What limits it to one domain?
--domain-filter.
Samajla ka?