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

Ingress resource LoadBalancer Service ExternalDNS watches resources reconciles DNS records Route53 / GCP DNS Cloudflare / Azure A/CNAME record

How ExternalDNS works

  1. ExternalDNS watches Ingress resources and LoadBalancer Services
  2. It reads the hostname from the Ingress spec.rules[].host or Service annotation
  3. It looks up the external IP or CNAME (from the Ingress/Service status)
  4. It calls the DNS provider API to create/update an A record (for IP) or CNAME record (for load balancer hostnames)
  5. 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:

PolicyCreates recordsUpdates recordsDeletes recordsWhen 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
🔴 Unique txtOwnerId is critical in multi-cluster If two ExternalDNS instances share the same 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

ProviderAuth methodKey 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?

A) The DNS record stays — ExternalDNS never deletes records it created
B) The DNS record is automatically deleted — policy=sync removes orphaned records
C> ExternalDNS marks the record as deprecated but keeps it for 7 days
D> You must manually delete the TXT ownership record first

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?

A) Set different domainFilters so each cluster manages a different DNS zone
B) Set a unique txtOwnerId per cluster — ExternalDNS only manages records it owns
C> Use policy=upsert-only on one cluster and policy=sync on the other
D> Route53 allows read-only access — only one cluster can have write access

Q3. Why should you start ExternalDNS with policy=upsert-only in a new deployment?

A> upsert-only is faster because it skips the deletion reconciliation loop
B> DNS propagation is faster with upsert-only
C) It prevents accidental deletion of existing records during initial configuration validation
D> upsert-only requires fewer IAM permissions than sync policy

Q4. ExternalDNS creates a CNAME record for an Ingress but your application team wants an A record. What annotation controls the record type?

A> Set spec.type: A in the Ingress manifest
B> Add external-dns.alpha.kubernetes.io/record-type: A
C> Change the Ingress class — nginx creates CNAME, traefik creates A records
D) Use external-dns.alpha.kubernetes.io/target: <IP> — ExternalDNS creates an A record when target is an IP