| name | external-dns |
| description | Comprehensive guide for configuring, troubleshooting, and implementing External-DNS across Azure DNS, AWS Route53, Cloudflare, and Google Cloud DNS. Use when implementing automatic DNS management in Kubernetes, configuring provider-specific authentication (managed identities, IRSA, API tokens), troubleshooting DNS synchronization issues, setting up secure production-grade external-dns deployments, optimizing performance, avoiding rate limits, or implementing GitOps patterns with ArgoCD. |
External-DNS Skill
Complete External-DNS operations for automatic DNS management in Kubernetes clusters.
Overview
External-DNS synchronizes exposed Kubernetes Services and Ingresses with DNS providers, eliminating manual DNS record management. This skill covers configuration, best practices, and troubleshooting across multiple DNS providers with emphasis on Azure and Cloudflare.
Provider Quick Reference
| Provider | Auth Method | Status | Reference |
|---|
| Azure DNS | Workload Identity (recommended) or Service Principal | Stable | references/azure-dns.md |
| Cloudflare | API Token | Beta | references/cloudflare.md |
| AWS Route53 | IRSA (recommended) or Access Keys | Stable | Below |
| Google Cloud DNS | Workload Identity | Stable | Below |
Essential Helm Values Structure
fullnameOverride: external-dns
provider:
name: <provider>
sources:
- service
- ingress
domainFilters:
- example.com
policy: upsert-only
interval: "5m"
txtOwnerId: "aks-cluster-name"
txtPrefix: "_externaldns."
logLevel: info
logFormat: json
resources:
requests:
memory: "64Mi"
cpu: "25m"
limits:
memory: "128Mi"
securityContext:
runAsNonRoot: true
runAsUser: 65534
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
serviceMonitor:
enabled: true
interval: 30s
Azure DNS Configuration
Workload Identity (Recommended)
provider:
name: azure
serviceAccount:
labels:
azure.workload.identity/use: "true"
annotations:
azure.workload.identity/client-id: "<MANAGED_IDENTITY_CLIENT_ID>"
podLabels:
azure.workload.identity/use: "true"
env:
- name: AZURE_TENANT_ID
value: "<TENANT_ID>"
- name: AZURE_SUBSCRIPTION_ID
value: "<SUBSCRIPTION_ID>"
- name: AZURE_RESOURCE_GROUP
value: "<DNS_ZONE_RESOURCE_GROUP>"
domainFilters:
- example.com
txtOwnerId: "aks-cluster-name"
policy: upsert-only
interval: "5m"
Required Azure RBAC Permissions
az role assignment create \
--role "DNS Zone Contributor" \
--assignee "<MANAGED_IDENTITY_OBJECT_ID>" \
--scope "/subscriptions/<SUB_ID>/resourceGroups/<RG>/providers/Microsoft.Network/dnszones/<ZONE>"
az role assignment create \
--role "Private DNS Zone Contributor" \
--assignee "<MANAGED_IDENTITY_OBJECT_ID>" \
--scope "/subscriptions/<SUB_ID>/resourceGroups/<RG>/providers/Microsoft.Network/privateDnsZones/<ZONE>"
Service Principal Alternative
provider:
name: azure
env:
- name: AZURE_TENANT_ID
value: "<TENANT_ID>"
- name: AZURE_SUBSCRIPTION_ID
value: "<SUBSCRIPTION_ID>"
- name: AZURE_RESOURCE_GROUP
value: "<DNS_ZONE_RESOURCE_GROUP>"
- name: AZURE_CLIENT_ID
valueFrom:
secretKeyRef:
name: azure-credentials
key: client-id
- name: AZURE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: azure-credentials
key: client-secret
Cloudflare Configuration
provider:
name: cloudflare
env:
- name: CF_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-api-token
key: cloudflare_api_token
extraArgs:
cloudflare-proxied: true
cloudflare-dns-records-per-page: 5000
domainFilters:
- example.com
txtOwnerId: "aks-cluster-name"
policy: upsert-only
Cloudflare API Token Permissions
- Zone:Read - List zones
- DNS:Edit - Create/update/delete DNS records
- Zone Resources: All zones or specific zones
AWS Route53 Configuration (IRSA)
provider:
name: aws
env:
- name: AWS_DEFAULT_REGION
value: "us-east-1"
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: "arn:aws:iam::<ACCOUNT_ID>:role/external-dns"
extraArgs:
aws-zone-type: public
aws-batch-change-size: 4000
domainFilters:
- example.com
txtOwnerId: "eks-cluster-name"
Required AWS IAM Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["route53:ChangeResourceRecordSets"],
"Resource": ["arn:aws:route53:::hostedzone/*"]
},
{
"Effect": "Allow",
"Action": ["route53:ListHostedZones", "route53:ListResourceRecordSets"],
"Resource": ["*"]
}
]
}
Google Cloud DNS Configuration
provider:
name: google
env:
- name: GOOGLE_PROJECT
value: "<GCP_PROJECT_ID>"
serviceAccount:
annotations:
iam.gke.io/gcp-service-account: "external-dns@<PROJECT_ID>.iam.gserviceaccount.com"
domainFilters:
- example.com
txtOwnerId: "gke-cluster-name"
Kubernetes Resource Annotations
Basic Usage
metadata:
annotations:
external-dns.alpha.kubernetes.io/hostname: "app.example.com"
external-dns.alpha.kubernetes.io/ttl: "300"
Multiple Hostnames
metadata:
annotations:
external-dns.alpha.kubernetes.io/hostname: "app1.example.com,app2.example.com"
Provider-Specific Annotations
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/alias: "true"
external-dns.alpha.kubernetes.io/ttl: "60"
Environment-Specific Best Practices
Development
policy: sync
interval: "1m"
logLevel: info
resources:
requests:
memory: "50Mi"
cpu: "10m"
limits:
memory: "50Mi"
Production
policy: upsert-only
interval: "10m"
logLevel: error
replicaCount: 2
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values: [external-dns]
topologyKey: kubernetes.io/hostname
podDisruptionBudget:
enabled: true
minAvailable: 1
priorityClassName: high-priority
Common Commands
kubectl get pods -n external-dns
kubectl logs -n external-dns deployment/external-dns --tail=100 -f
kubectl get deployment external-dns -n external-dns -o yaml | grep -A20 args
dig @1.1.1.1 app.example.com
az network dns record-set list -g <RESOURCE_GROUP> -z example.com -o table
dig TXT _externaldns.app.example.com
kubectl rollout restart deployment external-dns -n external-dns
extraArgs:
dry-run: true
Key Metrics
# Total endpoints managed
external_dns_registry_endpoints_total
# Sync errors
external_dns_controller_sync_errors_total
# Last sync timestamp
external_dns_controller_last_sync_timestamp_seconds
# DNS records by type
external_dns_registry_a_records
external_dns_registry_aaaa_records
external_dns_registry_cname_records
ArgoCD ApplicationSet Pattern
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: external-dns
namespace: argocd
spec:
generators:
- list:
elements:
- cluster: dev
branch: main
- cluster: prd
branch: main
template:
metadata:
name: 'external-dns-{{cluster}}'
spec:
project: infrastructure
sources:
- chart: external-dns
repoURL: https://kubernetes-sigs.github.io/external-dns/
targetRevision: "1.18.0"
helm:
releaseName: external-dns
valueFiles:
- $values/argo-cd-helm-values/kube-addons/external-dns/{{cluster}}/values.yaml
- repoURL: https://your-repo.git
targetRevision: "{{branch}}"
ref: values
destination:
server: '{{url}}'
namespace: external-dns
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Security Checklist
References
Gotchas
txtOwnerId collisions silently corrupt DNS across clusters: Two clusters with the same owner ID will reconcile each other's records into oblivion. Always use cluster-name + env (e.g., aks-example-app-prd) and verify with dig TXT _externaldns.<host>.
policy: sync deletes records External-DNS didn't create when names match patterns: A manually-created A record matching a managed hostname will be deleted on next reconcile. Production must be upsert-only; only dev clusters get sync.
- RBAC on K8s side AND DNS provider creds are both required: External-DNS needs to read Ingress/Service objects AND have DNS-zone write. Read-only DNS creds produce silent no-ops with zero events emitted to the watched resources — only the pod logs show the auth error.
- Workload Identity needs three things, not one: ServiceAccount annotation + pod label + federated credential on the managed identity. Missing the federated credential gives
ManagedIdentityCredential: 400 that looks like a token problem but is an identity-binding problem.
domainFilters is prefix-matching, not exact: domainFilters: [example.com] will manage evil-example.com if a hostile Ingress claims that hostname. Use --exclude-domains or stricter filtering on multi-tenant clusters.
- Azure Private DNS Zone needs a different role than public: "DNS Zone Contributor" only works on public zones; private zones need "Private DNS Zone Contributor". Assigning the wrong one returns 403 only when the first record sync fires, not at deploy time.