🌐 The Problem ExternalDNS Solves
Every time a developer deploys a new service or Ingress, someone has to manually create a DNS record. At scale this is a bottleneck — developers open tickets, ops teams update Route53/Cloudflare, records go stale when services are deleted, and environments drift. ExternalDNS eliminates this entirely by watching Kubernetes resources and synchronising them with your DNS provider automatically.
How ExternalDNS works
- ExternalDNS watches Ingress resources and LoadBalancer Services
- It reads the hostname from the Ingress
spec.rules[].hostor Service annotation - It looks up the external IP or CNAME (from the Ingress/Service status)
- It calls the DNS provider API to create/update an A record (for IP) or CNAME record (for load balancer hostnames)
- When the resource is deleted, ExternalDNS removes the DNS record
Installing ExternalDNS (Helm)
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update
# AWS Route53 with IRSA
helm install external-dns external-dns/external-dns \
--namespace external-dns --create-namespace \
--set provider=aws \
--set aws.zoneType=public \
--set txtOwnerId=my-cluster \
--set domainFilters[0]=example.com \
--set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=arn:aws:iam::123456789:role/external-dns
# Verify ExternalDNS is running
kubectl logs -n external-dns -l app.kubernetes.io/name=external-dns --tail=20
# time="..." level=info msg="All records are already up to date"
🔄 Sync Policies — How Safe Is ExternalDNS?
ExternalDNS has three sync policies that control how aggressively it manages DNS records. This is the most important configuration decision — getting it wrong can delete DNS records you didn't intend it to touch:
| Policy | Creates records | Updates records | Deletes records | When to use |
|---|---|---|---|---|
upsert-only |
✅ | ✅ | ❌ Never | Safe default for first rollout — no deletions, build trust |
sync (default) |
✅ | ✅ | ✅ Deletes orphaned records | Full GitOps mode — records removed when K8s resources deleted |
create-only |
✅ | ❌ | ❌ | One-shot provisioning; records never overwritten or deleted |
# Set sync policy via Helm
helm install external-dns external-dns/external-dns \
--set policy=upsert-only # start safe, switch to sync when confident
🔒 TXT Record Ownership — Preventing Conflicts
In a multi-cluster setup, multiple ExternalDNS instances might manage the same DNS zone. ExternalDNS uses TXT records as ownership markers to track which records it created — it will only modify or delete records it owns:
# Every DNS record ExternalDNS creates is paired with a TXT record:
# api.example.com A → 1.2.3.4
# txt-api.example.com TXT → "heritage=external-dns,external-dns/owner=prod-cluster,external-dns/resource=ingress/production/checkout-api"
# txtOwnerId must be UNIQUE per cluster to prevent conflicts
helm install external-dns external-dns/external-dns \
--set txtOwnerId=prod-us-east-1 # unique cluster identifier
--set txtPrefix=externaldns- # optional: prefix TXT records
txtOwnerId, one will think it owns records created by the other and may delete them. Always set a unique per-cluster ID (e.g. cluster name, region + environment). Use the same value consistently — changing it orphans existing TXT ownership records.
🎯 Domain Filtering — Scope ExternalDNS Safely
# Only manage records in specific zones (strongly recommended)
helm install external-dns external-dns/external-dns \
--set "domainFilters[0]=example.com" \
--set "domainFilters[1]=internal.example.com"
# Only manage specific namespaces
--set "namespaceFilter=production"
# Only manage Ingress (not Services) or vice versa
--set "sources[0]=ingress" \
--set "sources[1]=service"
# Exclude specific hostnames from management
--set "excludeDomains[0]=legacy.example.com"
# Annotation filter — only manage resources with this annotation
--set "annotationFilter=external-dns.alpha.kubernetes.io/managed=true"
Opt-in mode with annotation filter
The safest production pattern: require an explicit opt-in annotation on every resource before ExternalDNS touches it. This prevents accidentally managing DNS for resources that shouldn't be public:
# ExternalDNS deployed with annotation filter:
# --set annotationFilter="external-dns.alpha.kubernetes.io/managed=true"
# Only Ingresses/Services with this annotation are managed
metadata:
annotations:
external-dns.alpha.kubernetes.io/managed: "true"
external-dns.alpha.kubernetes.io/hostname: api.example.com
Combining ExternalDNS with cert-manager
The canonical production pattern: ExternalDNS handles DNS, cert-manager handles TLS. Add both annotations to your Ingress:
metadata:
annotations:
# cert-manager: issue and rotate the TLS certificate
cert-manager.io/cluster-issuer: letsencrypt-prod
# ExternalDNS: create the DNS record for the hostname
external-dns.alpha.kubernetes.io/hostname: api.example.com
external-dns.alpha.kubernetes.io/ttl: "300"
spec:
tls:
- hosts: [api.example.com]
secretName: api-example-com-tls
rules:
- host: api.example.com
...
📋 Ingress Integration
For Ingress resources, ExternalDNS reads the hostname from spec.rules[].host and the IP/CNAME from the Ingress status. No annotation needed when you configure ExternalDNS to watch all Ingresses — but annotations give you fine-grained control:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkout-api
namespace: production
annotations:
# Explicit hostname override (overrides spec.rules[].host)
external-dns.alpha.kubernetes.io/hostname: api.example.com
# Custom TTL for the DNS record
external-dns.alpha.kubernetes.io/ttl: "300"
# Targets override — point to a specific IP instead of the LB
external-dns.alpha.kubernetes.io/target: 1.2.3.4
spec:
ingressClassName: nginx
rules:
- host: api.example.com # ExternalDNS reads this automatically
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: checkout-api
port: { number: 8080 }
ExternalDNS creates: api.example.com → CNAME → <nginx-lb-hostname> or an A record if the LB has a static IP.
⚡ Service Integration (LoadBalancer)
For LoadBalancer Services, annotate the Service with the desired hostname. ExternalDNS creates an A record pointing to the Service's external IP:
apiVersion: v1
kind: Service
metadata:
name: grpc-api
namespace: production
annotations:
external-dns.alpha.kubernetes.io/hostname: grpc.example.com
external-dns.alpha.kubernetes.io/ttl: "60" # low TTL for fast failover
spec:
type: LoadBalancer
selector:
app: grpc-api
ports:
- port: 443
targetPort: 8443
☁️ Provider Configuration
| Provider | Auth method | Key Helm values |
|---|---|---|
| AWS Route53 | IRSA (pod identity) — no static keys | provider=aws, aws.zoneType=public, txtOwnerId=cluster-name |
| GCP Cloud DNS | Workload Identity or Service Account key | provider=google, google.project=my-project |
| Azure DNS | Managed Identity or Service Principal | provider=azure, azure.resourceGroup=rg-name |
| Cloudflare | API token (Secret) | provider=cloudflare, cloudflare.apiToken=<token> |
| CoreDNS (in-cluster) | Direct etcd access | provider=coredns — for internal cluster DNS |
Route53 IAM policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets" ← create/update/delete records
],
"Resource": "arn:aws:route53:::hostedzone/*"
},
{
"Effect": "Allow",
"Action": [
"route53:ListHostedZones", ← discover which zones exist
"route53:ListResourceRecordSets", ← read existing records
"route53:ListTagsForResource"
],
"Resource": "*"
}
]
}
🧠 Knowledge Check
Q1. You deploy ExternalDNS with policy=sync and later delete an Ingress. What happens to the DNS record?
Q2. You have two clusters both running ExternalDNS managing the same Route53 hosted zone. What is the critical configuration to prevent them from deleting each other's records?
txtOwnerId per cluster — ExternalDNS only manages records it ownsQ3. Why should you start ExternalDNS with policy=upsert-only in a new deployment?
Q4. ExternalDNS creates a CNAME record for an Ingress but your application team wants an A record. What annotation controls the record type?
spec.type: A in the Ingress manifestexternal-dns.alpha.kubernetes.io/record-type: Aexternal-dns.alpha.kubernetes.io/target: <IP> — ExternalDNS creates an A record when target is an IP