
External Dns
- 94 installs
- 6 repo stars
- Updated July 22, 2026
- julianobarbosa/claude-code-skills
Manage DNS records and external DNS integration.
About
Comprehensive guide for configuring, troubleshooting, and implementing External-DNS across Azure DNS, AWS Route53, Cloudflare, and Google Cloud DNS.
- Zone:Read - List zones
- DNS:Edit - Create/update/delete DNS records
External Dns by the numbers
- 94 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #572 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/julianobarbosa/claude-code-skills --skill external-dnsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 94 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 22, 2026 |
| Repository | julianobarbosa/claude-code-skills ↗ |
What it does
Manage DNS records and external DNS integration.
Files
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
# kubernetes-sigs/external-dns chart (v1.18.0+)
fullnameOverride: external-dns
provider:
name: <provider> # azure, cloudflare, aws, google
# Sources to watch
sources:
- service
- ingress
# Domain restrictions
domainFilters:
- example.com
# Policy: sync (creates/updates/deletes) or upsert-only (creates/updates only)
policy: upsert-only # Recommended for production
# Sync interval
interval: "5m"
# TXT record ownership (MUST be unique per cluster)
txtOwnerId: "aks-cluster-name"
txtPrefix: "_externaldns."
# Logging
logLevel: info # debug, info, warning, error
logFormat: json
# Resources
resources:
requests:
memory: "64Mi"
cpu: "25m"
limits:
memory: "128Mi"
# cpu: REMOVED per best practice (no CPU limits)
# Security context
securityContext:
runAsNonRoot: true
runAsUser: 65534
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# Prometheus metrics
serviceMonitor:
enabled: true
interval: 30sAzure 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
# Assign DNS Zone Contributor role to the managed identity
az role assignment create \
--role "DNS Zone Contributor" \
--assignee "<MANAGED_IDENTITY_OBJECT_ID>" \
--scope "/subscriptions/<SUB_ID>/resourceGroups/<RG>/providers/Microsoft.Network/dnszones/<ZONE>"
# For Private DNS Zones
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-secretCloudflare Configuration
provider:
name: cloudflare
env:
- name: CF_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-api-token
key: cloudflare_api_token
extraArgs:
cloudflare-proxied: true # Enable CDN/DDoS protection
cloudflare-dns-records-per-page: 5000 # Optimize API calls
domainFilters:
- example.com
txtOwnerId: "aks-cluster-name"
policy: upsert-onlyCloudflare 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 # or private
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
# On Service or Ingress
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
# Cloudflare - disable proxy for specific record
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
# AWS Route53 - create ALIAS record
external-dns.alpha.kubernetes.io/alias: "true"
# Custom TTL
external-dns.alpha.kubernetes.io/ttl: "60"Environment-Specific Best Practices
Development
policy: sync # Auto-delete orphaned records
interval: "1m" # Fast sync for rapid iteration
logLevel: info
resources:
requests:
memory: "50Mi"
cpu: "10m"
limits:
memory: "50Mi"Production
policy: upsert-only # NEVER auto-delete
interval: "10m" # Conservative to reduce API load
logLevel: error # Minimal logging
# High Availability
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-priorityCommon Commands
# Check external-dns pods
kubectl get pods -n external-dns
# View logs
kubectl logs -n external-dns deployment/external-dns --tail=100 -f
# Check configuration
kubectl get deployment external-dns -n external-dns -o yaml | grep -A20 args
# Verify DNS records (Cloudflare)
dig @1.1.1.1 app.example.com
# Verify DNS records (Azure)
az network dns record-set list -g <RESOURCE_GROUP> -z example.com -o table
# Check TXT ownership records
dig TXT _externaldns.app.example.com
# Force restart
kubectl rollout restart deployment external-dns -n external-dns
# Dry-run mode (add to extraArgs)
extraArgs:
dry-run: trueKey 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_recordsArgoCD 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=trueSecurity Checklist
- [ ] Use Workload Identity/IRSA instead of static credentials
- [ ] Grant least privilege permissions to DNS zones
- [ ] Set
runAsNonRoot: trueandreadOnlyRootFilesystem: true - [ ] Use unique
txtOwnerIdper cluster - [ ] Restrict
domainFiltersto necessary domains - [ ] Store API tokens in Kubernetes Secrets
- [ ] Enable Pod Security Standards (restricted)
- [ ] Use
policy: upsert-onlyin production
References
references/azure-dns.md- Complete Azure DNS configuration guidereferences/cloudflare.md- Complete Cloudflare configuration guidereferences/troubleshooting.md- Common issues and solutions- Official docs: <https://kubernetes-sigs.github.io/external-dns/>
- Helm chart: <https://artifacthub.io/packages/helm/external-dns/external-dns>
---
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-cafehyna-prd) and verify withdig 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 getsync. - 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: 400that looks like a token problem but is an identity-binding problem. - `domainFilters` is prefix-matching, not exact:
domainFilters: [example.com]will manageevil-example.comif a hostile Ingress claims that hostname. Use--exclude-domainsor 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.
External-DNS Azure DNS Configuration Reference
Complete guide for configuring External-DNS with Azure DNS (public and private zones) in AKS clusters.
Authentication Methods
Azure DNS supports three authentication methods with External-DNS:
| Method | Security | Complexity | Recommended For |
|---|---|---|---|
| Workload Identity | Highest | Medium | AKS 1.22+ (Production) |
| Managed Identity (Pod Identity) | High | Medium | Legacy AKS |
| Service Principal | Medium | Low | Dev/Testing |
Workload Identity Configuration (Recommended)
Prerequisites
1. AKS cluster with Workload Identity enabled 2. User-Assigned Managed Identity 3. Federated credential configured 4. DNS Zone Contributor role assigned
Step 1: Create Managed Identity
# Variables
RESOURCE_GROUP="rg-myapp"
IDENTITY_NAME="id-external-dns"
LOCATION="eastus"
# Create identity
az identity create \
--name $IDENTITY_NAME \
--resource-group $RESOURCE_GROUP \
--location $LOCATION
# Get identity details
IDENTITY_CLIENT_ID=$(az identity show --name $IDENTITY_NAME --resource-group $RESOURCE_GROUP --query clientId -o tsv)
IDENTITY_OBJECT_ID=$(az identity show --name $IDENTITY_NAME --resource-group $RESOURCE_GROUP --query principalId -o tsv)
IDENTITY_RESOURCE_ID=$(az identity show --name $IDENTITY_NAME --resource-group $RESOURCE_GROUP --query id -o tsv)Step 2: Assign DNS Zone Permissions
# For Public DNS Zone
DNS_ZONE_RESOURCE_GROUP="rg-dns"
DNS_ZONE_NAME="example.com"
DNS_ZONE_ID="/subscriptions/<SUB_ID>/resourceGroups/$DNS_ZONE_RESOURCE_GROUP/providers/Microsoft.Network/dnszones/$DNS_ZONE_NAME"
az role assignment create \
--role "DNS Zone Contributor" \
--assignee $IDENTITY_OBJECT_ID \
--scope $DNS_ZONE_ID
# For Private DNS Zone
PRIVATE_DNS_ZONE_ID="/subscriptions/<SUB_ID>/resourceGroups/$DNS_ZONE_RESOURCE_GROUP/providers/Microsoft.Network/privateDnsZones/$DNS_ZONE_NAME"
az role assignment create \
--role "Private DNS Zone Contributor" \
--assignee $IDENTITY_OBJECT_ID \
--scope $PRIVATE_DNS_ZONE_IDStep 3: Create Federated Credential
# Get AKS OIDC issuer URL
AKS_CLUSTER_NAME="aks-myapp"
AKS_RESOURCE_GROUP="rg-myapp"
AKS_OIDC_ISSUER=$(az aks show --name $AKS_CLUSTER_NAME --resource-group $AKS_RESOURCE_GROUP --query oidcIssuerProfile.issuerUrl -o tsv)
# Create federated credential
az identity federated-credential create \
--name "external-dns-federated" \
--identity-name $IDENTITY_NAME \
--resource-group $RESOURCE_GROUP \
--issuer $AKS_OIDC_ISSUER \
--subject "system:serviceaccount:external-dns:external-dns" \
--audiences "api://AzureADTokenExchange"Step 4: Helm Values Configuration
# values.yaml for Workload Identity
fullnameOverride: external-dns
provider:
name: azure
# Workload Identity configuration
serviceAccount:
create: true
name: external-dns
labels:
azure.workload.identity/use: "true"
annotations:
azure.workload.identity/client-id: "<IDENTITY_CLIENT_ID>"
podLabels:
azure.workload.identity/use: "true"
# Azure-specific environment variables
env:
- name: AZURE_TENANT_ID
value: "<TENANT_ID>"
- name: AZURE_SUBSCRIPTION_ID
value: "<SUBSCRIPTION_ID>"
- name: AZURE_RESOURCE_GROUP
value: "<DNS_ZONE_RESOURCE_GROUP>" # Resource group containing DNS zone
# Source configuration
sources:
- service
- ingress
# Domain filter - IMPORTANT: restrict to your domains
domainFilters:
- example.com
- subdomain.example.com
# TXT record ownership (must be unique per cluster)
txtOwnerId: "aks-myapp-eastus"
txtPrefix: "_externaldns."
# Policy
policy: upsert-only # Production: upsert-only, Dev: sync
# Sync interval
interval: "5m"
# Logging
logLevel: info
logFormat: json
# Resources
resources:
requests:
memory: "64Mi"
cpu: "25m"
limits:
memory: "128Mi"
# No CPU limits per AKS best practices
# Security context
securityContext:
runAsNonRoot: true
runAsUser: 65534
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# Pod security context
podSecurityContext:
runAsNonRoot: true
runAsUser: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefaultService Principal Configuration (Alternative)
Step 1: Create Service Principal
# Create SP with DNS Zone Contributor role
SP_NAME="sp-external-dns"
DNS_ZONE_ID="/subscriptions/<SUB_ID>/resourceGroups/<RG>/providers/Microsoft.Network/dnszones/<ZONE>"
az ad sp create-for-rbac \
--name $SP_NAME \
--role "DNS Zone Contributor" \
--scopes $DNS_ZONE_ID \
--output json
# Save output:
# {
# "appId": "<CLIENT_ID>",
# "displayName": "sp-external-dns",
# "password": "<CLIENT_SECRET>",
# "tenant": "<TENANT_ID>"
# }Step 2: Create Kubernetes Secret
kubectl create namespace external-dns
kubectl create secret generic azure-credentials \
--namespace external-dns \
--from-literal=client-id=<CLIENT_ID> \
--from-literal=client-secret=<CLIENT_SECRET>Step 3: Helm Values Configuration
# values.yaml for Service Principal
fullnameOverride: external-dns
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
sources:
- service
- ingress
domainFilters:
- example.com
txtOwnerId: "aks-myapp"
policy: upsert-only
interval: "5m"Azure Private DNS Zone Configuration
For private DNS zones, use the azure-private-dns provider:
provider:
name: azure-private-dns
env:
- name: AZURE_TENANT_ID
value: "<TENANT_ID>"
- name: AZURE_SUBSCRIPTION_ID
value: "<SUBSCRIPTION_ID>"
- name: AZURE_RESOURCE_GROUP
value: "<PRIVATE_DNS_ZONE_RESOURCE_GROUP>"
# Workload Identity labels
serviceAccount:
labels:
azure.workload.identity/use: "true"
annotations:
azure.workload.identity/client-id: "<IDENTITY_CLIENT_ID>"
podLabels:
azure.workload.identity/use: "true"
domainFilters:
- internal.example.com
txtOwnerId: "aks-myapp-private"Multiple DNS Zones Configuration
When managing multiple DNS zones in different resource groups:
provider:
name: azure
env:
- name: AZURE_TENANT_ID
value: "<TENANT_ID>"
- name: AZURE_SUBSCRIPTION_ID
value: "<SUBSCRIPTION_ID>"
# Use extraArgs for multiple resource groups
extraArgs:
azure-resource-group: "" # Empty to use zone-specific resource groups
# Domain filters for all zones
domainFilters:
- zone1.example.com
- zone2.example.comNote: For zones in different resource groups, you need to grant the identity permissions on each zone and may need to deploy separate External-DNS instances.
Azure RBAC Roles Reference
| Role | Permissions | Use Case |
|---|---|---|
| DNS Zone Contributor | Full access to DNS zones (no network access) | Public DNS zones |
| Private DNS Zone Contributor | Full access to private DNS zones | Private DNS zones |
| Reader | Read-only access | Troubleshooting only |
Custom Role (Minimum Permissions)
{
"Name": "External DNS Operator",
"Description": "Allows External-DNS to manage DNS records",
"Actions": [
"Microsoft.Network/dnsZones/read",
"Microsoft.Network/dnsZones/A/read",
"Microsoft.Network/dnsZones/A/write",
"Microsoft.Network/dnsZones/A/delete",
"Microsoft.Network/dnsZones/AAAA/read",
"Microsoft.Network/dnsZones/AAAA/write",
"Microsoft.Network/dnsZones/AAAA/delete",
"Microsoft.Network/dnsZones/CNAME/read",
"Microsoft.Network/dnsZones/CNAME/write",
"Microsoft.Network/dnsZones/CNAME/delete",
"Microsoft.Network/dnsZones/TXT/read",
"Microsoft.Network/dnsZones/TXT/write",
"Microsoft.Network/dnsZones/TXT/delete"
],
"NotActions": [],
"AssignableScopes": [
"/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<RESOURCE_GROUP>"
]
}Validation Commands
Check Identity Assignment
# Verify role assignment
az role assignment list --assignee $IDENTITY_OBJECT_ID --scope $DNS_ZONE_ID -o table
# Verify federated credential
az identity federated-credential list --identity-name $IDENTITY_NAME --resource-group $RESOURCE_GROUP -o tableCheck External-DNS Status
# Check pods
kubectl get pods -n external-dns -l app.kubernetes.io/name=external-dns
# Check logs for Azure authentication
kubectl logs -n external-dns deployment/external-dns | grep -i azure
# Check for authentication errors
kubectl logs -n external-dns deployment/external-dns | grep -i "error\|unauthorized\|forbidden"Verify DNS Records
# List A records in zone
az network dns record-set a list -g <RESOURCE_GROUP> -z example.com -o table
# List TXT records (ownership)
az network dns record-set txt list -g <RESOURCE_GROUP> -z example.com -o table
# Query specific record
az network dns record-set a show -g <RESOURCE_GROUP> -z example.com -n myapp
# Test resolution
nslookup myapp.example.com
dig myapp.example.com @168.63.129.16 # Azure DNS resolverCommon Issues and Solutions
Issue: Authentication Failed
Symptoms: authorization failed or client credentials errors in logs
Solutions:
1. Verify AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID are correct 2. Check Workload Identity labels are set on both ServiceAccount and Pod 3. Verify federated credential subject matches: system:serviceaccount:<namespace>:<sa-name> 4. Check role assignment scope includes the DNS zone
Issue: No DNS Records Created
Symptoms: External-DNS runs but no records appear in Azure
Solutions:
1. Verify domainFilters includes your domain 2. Check AZURE_RESOURCE_GROUP points to the zone's resource group 3. Verify source type is correct (service/ingress) 4. Check for dry-run: true in extraArgs
Issue: Rate Limiting
Symptoms: 429 Too Many Requests errors
Solutions:
1. Increase interval (e.g., from 1m to 5m) 2. Reduce number of watched namespaces 3. Use --azure-batch-change-size to batch updates
Issue: TXT Record Conflicts
Symptoms: TXT record is already in use errors
Solutions:
1. Verify txtOwnerId is unique per cluster 2. Manually delete orphaned TXT records 3. Use different txtPrefix if needed
High Availability Configuration
# Production HA configuration
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: system-cluster-critical
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: external-dnsAzure-Specific Annotations
# Custom TTL (seconds)
external-dns.alpha.kubernetes.io/ttl: "300"
# Target for the record
external-dns.alpha.kubernetes.io/target: "20.10.5.100"
# Multiple hostnames
external-dns.alpha.kubernetes.io/hostname: "app.example.com,api.example.com"Integration with Azure Application Gateway Ingress Controller (AGIC)
When using AGIC, External-DNS can read the Application Gateway's public IP:
sources:
- ingress
# AGIC Ingress Class
extraArgs:
ingress-class: azure-application-gatewayMonitoring and Alerting
Prometheus Alert Rules
groups:
- name: external-dns
rules:
- alert: ExternalDNSSyncErrors
expr: increase(external_dns_controller_sync_errors_total[5m]) > 0
for: 10m
labels:
severity: warning
annotations:
summary: External-DNS sync errors detected
- alert: ExternalDNSNotSyncing
expr: time() - external_dns_controller_last_sync_timestamp_seconds > 900
for: 5m
labels:
severity: critical
annotations:
summary: External-DNS has not synced in 15 minutesReferences
External-DNS Cloudflare Configuration Reference
Complete guide for configuring External-DNS with Cloudflare DNS, including proxy settings, API optimization, and production best practices.
Authentication Methods
| Method | Security | Recommended |
|---|---|---|
| API Token | High (scoped permissions) | Production |
| API Key | Lower (global access) | Not recommended |
API Token Configuration (Recommended)
Step 1: Create API Token in Cloudflare
1. Go to Cloudflare Dashboard > My Profile > API Tokens 2. Click "Create Token" 3. Use "Custom token" template 4. Configure permissions:
| Permission | Access Level | Required |
|---|---|---|
| Zone > Zone | Read | Yes |
| Zone > DNS | Edit | Yes |
5. Zone Resources: Select "All zones" or specific zones 6. Click "Continue to summary" > "Create Token" 7. Save the token immediately - it won't be shown again
Step 2: Create Kubernetes Secret
kubectl create namespace external-dns
kubectl create secret generic cloudflare-api-token \
--namespace external-dns \
--from-literal=cloudflare_api_token=<YOUR_API_TOKEN>Step 3: Helm Values Configuration
# values.yaml for Cloudflare
fullnameOverride: external-dns
provider:
name: cloudflare
# Authentication via secret
env:
- name: CF_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-api-token
key: cloudflare_api_token
# Cloudflare-specific configuration
extraArgs:
cloudflare-proxied: true # Enable Cloudflare proxy (CDN/DDoS)
cloudflare-dns-records-per-page: 5000 # API pagination optimization
# Source configuration
sources:
- service
- ingress
# Domain filter - restrict to your domains
domainFilters:
- example.com
- subdomain.example.com
# TXT record ownership (must be unique per cluster)
txtOwnerId: "aks-cluster-name"
txtPrefix: "_externaldns."
# Policy
policy: upsert-only # Production: upsert-only, Dev: sync
# Sync interval
interval: "5m"
# Logging
logLevel: info
logFormat: json
# Resources
resources:
requests:
memory: "64Mi"
cpu: "25m"
limits:
memory: "128Mi"Cloudflare Proxy Feature
Proxied vs DNS-Only Records
| Setting | Proxied (true) | DNS-Only (false) |
|---|---|---|
| Traffic routing | Through Cloudflare edge | Direct to origin |
| DDoS protection | Yes | No |
| CDN caching | Yes | No |
| SSL termination | At Cloudflare edge | At origin |
| Origin IP | Hidden | Exposed |
| Supported protocols | HTTP/HTTPS only | All (TCP, UDP) |
Global Proxy Configuration
# Enable proxy globally (recommended for web traffic)
extraArgs:
cloudflare-proxied: truePer-Record Proxy Override
Override the global setting using annotations:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api
annotations:
# Disable proxy for this specific record
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 80When to Disable Proxy
- WebSocket connections (if not using Cloudflare Websocket support)
- Non-HTTP protocols (MQTT, custom TCP)
- Services requiring direct IP access
- SSH access endpoints
- Mail servers (MX records)
API Optimization
Records Per Page
extraArgs:
cloudflare-dns-records-per-page: 5000 # Default: 100, Max: 5000Benefits:
- Fewer API calls for zones with many records
- Reduced risk of rate limiting
- Faster sync cycles
Sync Interval Tuning
# Development: Fast feedback
interval: "1m"
# Staging: Balanced
interval: "5m"
# Production: Conservative
interval: "10m"Zone ID Filter
For accounts with many zones, filter to specific zone IDs:
extraArgs:
zone-id-filter: "zone_id_1,zone_id_2"Get zone IDs:
curl -X GET "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" | jq '.result[] | {name, id}'Cloudflare-Specific Annotations
Basic Annotations
# Custom hostname
external-dns.alpha.kubernetes.io/hostname: "app.example.com"
# Custom TTL (seconds, minimum: 60 for proxied, 1 for non-proxied)
external-dns.alpha.kubernetes.io/ttl: "300"
# Proxy override
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"Advanced Annotations
# Multiple hostnames
external-dns.alpha.kubernetes.io/hostname: "app.example.com,www.example.com"
# Target IP override
external-dns.alpha.kubernetes.io/target: "203.0.113.10"
# Record comment (visible in Cloudflare dashboard)
# Added automatically by external-dnsMulti-Domain Configuration
Same Cloudflare Account
domainFilters:
- domain1.com
- domain2.com
- subdomain.domain3.com
# All domains managed by the same API token
env:
- name: CF_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-api-token
key: cloudflare_api_tokenDifferent Cloudflare Accounts
Deploy separate External-DNS instances per account:
# Instance 1: domain1.com
fullnameOverride: external-dns-domain1
env:
- name: CF_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-token-domain1
key: token
domainFilters:
- domain1.com
txtOwnerId: "aks-cluster-domain1"
# Instance 2: domain2.com (separate deployment)
fullnameOverride: external-dns-domain2
env:
- name: CF_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-token-domain2
key: token
domainFilters:
- domain2.com
txtOwnerId: "aks-cluster-domain2"Validation Commands
Test API Token
# Verify token permissions
curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json"
# List zones accessible to token
curl -X GET "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result[] | {name, id}'Check External-DNS Logs
# Watch logs
kubectl logs -n external-dns deployment/external-dns -f
# Check for Cloudflare-specific messages
kubectl logs -n external-dns deployment/external-dns | grep -i cloudflare
# Check for errors
kubectl logs -n external-dns deployment/external-dns | grep -i errorVerify DNS Records
# Query Cloudflare DNS directly
dig @1.1.1.1 app.example.com
# Check TXT ownership records
dig @1.1.1.1 TXT _externaldns.app.example.com
# Full trace
dig +trace app.example.comList Records via Cloudflare API
ZONE_ID="your-zone-id"
# List all DNS records
curl -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result[] | {name, type, content, proxied}'
# List TXT records only
curl -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=TXT" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result[] | {name, content}'Common Issues and Solutions
Issue: Authentication Failed
Symptoms: 401 Unauthorized or 403 Forbidden in logs
Solutions:
1. Verify API token is correct in secret 2. Check token has Zone:Read and DNS:Edit permissions 3. Verify zone is accessible to the token (Zone Resources setting) 4. Regenerate token if expired
# Test token
curl "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer $CF_API_TOKEN"Issue: Rate Limiting
Symptoms: 429 Too Many Requests in logs
Solutions:
1. Increase sync interval:
interval: "10m" # Increase from default2. Increase records per page:
extraArgs:
cloudflare-dns-records-per-page: 50003. Use zone ID filter to reduce API calls:
extraArgs:
zone-id-filter: "<specific-zone-id>"Issue: Proxy Not Working
Symptoms: Records created but not proxied
Solutions:
1. Verify cloudflare-proxied: true in extraArgs 2. Check per-record annotations aren't overriding 3. Note: Some record types cannot be proxied (MX, TXT, etc.)
Issue: TXT Record Conflicts
Symptoms: TXT record ownership conflict in logs
Solutions:
1. Ensure unique txtOwnerId per cluster 2. Delete orphaned TXT records manually 3. Use different txtPrefix if migrating
# Find and delete orphaned TXT records
curl -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=TXT&name=_externaldns" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result[] | {id, name, content}'Issue: Records Not Created
Symptoms: Ingress/Service deployed but no DNS record
Solutions:
1. Check domainFilters includes your domain 2. Verify source type (service/ingress) is in sources 3. Check dry-run is not enabled 4. Verify Ingress has a host defined
High Availability Configuration
# Production HA configuration
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: 1Security Best Practices
API Token Rotation
# Create new token in Cloudflare dashboard
# Update Kubernetes secret
kubectl create secret generic cloudflare-api-token \
--namespace external-dns \
--from-literal=cloudflare_api_token=<NEW_TOKEN> \
--dry-run=client -o yaml | kubectl apply -f -
# Restart external-dns to pick up new secret
kubectl rollout restart deployment external-dns -n external-dns
# Delete old token in Cloudflare dashboardMinimal Permissions Token
Create token with minimal required permissions:
1. Zone > Zone > Read - List zones 2. Zone > DNS > Edit - Manage DNS records 3. Zone Resources - Include only required zones
Network Policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: external-dns
namespace: external-dns
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: external-dns
policyTypes:
- Egress
egress:
# Allow DNS resolution
- to: []
ports:
- protocol: UDP
port: 53
# Allow Cloudflare API
- to:
- ipBlock:
cidr: 104.16.0.0/12 # Cloudflare IPs
ports:
- protocol: TCP
port: 443
# Allow Kubernetes API
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
component: kube-apiserver
ports:
- protocol: TCP
port: 443Monitoring
Prometheus Metrics
# Sync errors
rate(external_dns_controller_sync_errors_total[5m])
# Records managed
external_dns_registry_endpoints_total
# Last sync time
time() - external_dns_controller_last_sync_timestamp_secondsAlert Rules
groups:
- name: external-dns-cloudflare
rules:
- alert: CloudflareRateLimited
expr: increase(external_dns_controller_sync_errors_total{error="rate_limited"}[5m]) > 0
for: 5m
labels:
severity: warning
annotations:
summary: External-DNS is being rate limited by Cloudflare
- alert: CloudflareAuthFailure
expr: increase(external_dns_controller_sync_errors_total{error="unauthorized"}[5m]) > 0
for: 1m
labels:
severity: critical
annotations:
summary: External-DNS Cloudflare authentication failureReferences
External-DNS Troubleshooting Guide
Comprehensive troubleshooting guide for External-DNS across all supported DNS providers.
Quick Diagnostic Commands
# 1. Check if External-DNS is running
kubectl get pods -n external-dns -l app.kubernetes.io/name=external-dns
# 2. Check recent logs
kubectl logs -n external-dns deployment/external-dns --tail=100
# 3. Check for errors in logs
kubectl logs -n external-dns deployment/external-dns | grep -i "error\|warn\|fail"
# 4. Describe pod for events
kubectl describe pod -n external-dns -l app.kubernetes.io/name=external-dns
# 5. Check configuration
kubectl get deployment external-dns -n external-dns -o yaml | grep -A 30 args
# 6. Check service account
kubectl get sa external-dns -n external-dns -o yaml
# 7. Restart external-dns
kubectl rollout restart deployment external-dns -n external-dnsIssue Categories
1. DNS Records Not Created
Symptoms
- Ingress/Service deployed but no DNS record in provider
- Logs show "no endpoints generated"
Diagnostic Steps
# Check if external-dns is detecting the resource
kubectl logs -n external-dns deployment/external-dns | grep "Desired change"
# Verify the Ingress/Service exists and has correct annotations
kubectl get ingress <name> -o yaml
kubectl get service <name> -o yaml
# Check domain filters
kubectl get deployment external-dns -n external-dns -o yaml | grep domain-filterCommon Causes and Solutions
Domain Not in Filter
Problem: Hostname doesn't match domainFilters
# Values has:
domainFilters:
- example.com
# But Ingress uses:
host: app.other-domain.com # Won't be managedSolution: Add domain to filter or remove filter to manage all domains:
domainFilters:
- example.com
- other-domain.comSource Type Not Configured
Problem: Watching wrong source type
# Values has:
sources:
- service # Only watching services
# But using:
kind: Ingress # Won't be detectedSolution: Add correct source type:
sources:
- service
- ingressMissing Hostname
Problem: Ingress without explicit host
# Missing host or annotation
spec:
rules:
- http: # No host specifiedSolution: Add hostname:
spec:
rules:
- host: app.example.com
http:
paths: [...]Service Not LoadBalancer
Problem: Service type doesn't expose external IP
spec:
type: ClusterIP # No external IPSolution: Use LoadBalancer or add annotation:
spec:
type: LoadBalancer
# Or for ClusterIP with annotation:
metadata:
annotations:
external-dns.alpha.kubernetes.io/hostname: app.example.com2. Authentication Failures
Symptoms
401 Unauthorizedor403 Forbiddenin logsauthorization failedmessages
Provider-Specific Solutions
Azure DNS
# Check environment variables
kubectl exec -n external-dns deployment/external-dns -- env | grep AZURE
# Verify Workload Identity labels
kubectl get pod -n external-dns -l app.kubernetes.io/name=external-dns -o yaml | grep "azure.workload.identity"
# Check service account annotations
kubectl get sa external-dns -n external-dns -o yaml | grep azure
# Verify role assignment
az role assignment list --assignee <IDENTITY_OBJECT_ID> --scope <DNS_ZONE_ID> -o tableSolutions:
1. Verify AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP are correct 2. Ensure Workload Identity labels are on both ServiceAccount AND Pod 3. Check federated credential subject: system:serviceaccount:external-dns:external-dns 4. Verify DNS Zone Contributor role is assigned to the managed identity
Cloudflare
# Test API token
curl "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer $(kubectl get secret cloudflare-api-token -n external-dns -o jsonpath='{.data.cloudflare_api_token}' | base64 -d)"
# List accessible zones
curl "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result[].name'Solutions:
1. Regenerate API token with correct permissions (Zone:Read, DNS:Edit) 2. Verify Zone Resources includes your zones 3. Update Kubernetes secret with new token
AWS Route53
# Check IAM role annotation
kubectl get sa external-dns -n external-dns -o yaml | grep eks.amazonaws.com/role-arn
# Test assume role (from cluster)
kubectl exec -n external-dns deployment/external-dns -- aws sts get-caller-identitySolutions:
1. Verify IRSA role annotation on ServiceAccount 2. Check IAM policy has route53 permissions 3. Ensure OIDC provider is configured for cluster
3. TXT Record Conflicts
Symptoms
TXT record ownership conflictin logs- Records not being updated
another external-dns instance owns this record
Diagnostic Steps
# Check current TXT ownership records
dig TXT _externaldns.app.example.com
# For Azure
az network dns record-set txt list -g <RG> -z <ZONE> | grep external-dns
# For Cloudflare
curl "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=TXT" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result[] | select(.name | contains("externaldns"))'Solutions
Different txtOwnerId per Cluster
# Cluster 1
txtOwnerId: "aks-cluster-1-eastus"
# Cluster 2
txtOwnerId: "aks-cluster-2-westus"Clean Up Orphaned TXT Records
# For Azure - delete orphaned TXT record
az network dns record-set txt delete -g <RG> -z <ZONE> -n _externaldns.app --yes
# For Cloudflare
RECORD_ID="<record-id-from-list>"
curl -X DELETE "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-H "Authorization: Bearer $CF_API_TOKEN"Change TXT Prefix
# If migrating from another txtOwnerId
txtPrefix: "_externaldns2." # Use different prefix temporarily4. Rate Limiting
Symptoms
429 Too Many Requestsin logs- DNS updates delayed
- Intermittent sync failures
Solutions
Increase Sync Interval
# Development
interval: "1m"
# Production (recommended)
interval: "10m"Optimize API Calls
extraArgs:
# Cloudflare - reduce API calls
cloudflare-dns-records-per-page: 5000
# AWS - batch changes
aws-batch-change-size: 4000
aws-zones-cache-duration: 3hFilter Zones
# Cloudflare - specific zone IDs
extraArgs:
zone-id-filter: "zone-id-1,zone-id-2"
# AWS - specific hosted zones
extraArgs:
aws-zone-match-parent: true5. High Memory/CPU Usage
Symptoms
- Pods OOMKilled
- Slow sync cycles
- CPU throttling
Solutions
Increase Resource Limits
resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "256Mi"
# No CPU limit recommendedReduce Scope
# Limit to specific namespaces
extraArgs:
namespace: "app-namespace"
# Or exclude namespaces
extraArgs:
ignore-hostname-annotation: trueIncrease Interval
interval: "15m" # Reduce sync frequency6. Records Not Deleted (Expected Behavior)
Symptoms
- Old DNS records remain after Ingress/Service deletion
- Records accumulate over time
Explanation
This is expected when using policy: upsert-only (recommended for production).
| Policy | Creates | Updates | Deletes |
|---|---|---|---|
sync | Yes | Yes | Yes |
upsert-only | Yes | Yes | No |
Solutions
For production: Manually delete records via DNS provider dashboard
For development: Use sync policy:
policy: sync # Only in dev environments7. SSL/TLS Issues
Symptoms
- Connection refused to provider API
- Certificate verification errors
Solutions
Skip TLS Verification (Not Recommended)
extraArgs:
tls-verify: false # Only for testingUpdate CA Certificates
# Mount custom CA bundle
volumes:
- name: ca-bundle
configMap:
name: custom-ca-bundle
volumeMounts:
- name: ca-bundle
mountPath: /etc/ssl/certs
readOnly: true8. Pods Not Starting
Symptoms
- Pods in CrashLoopBackOff
- Init container failures
Diagnostic Steps
# Check pod events
kubectl describe pod -n external-dns -l app.kubernetes.io/name=external-dns
# Check for image pull errors
kubectl get events -n external-dns --sort-by='.lastTimestamp'
# Check resource constraints
kubectl top pods -n external-dnsSolutions
Image Pull Errors
# Verify image exists
image:
repository: registry.k8s.io/external-dns/external-dns
tag: v0.18.0
# Or use specific registry
image:
repository: gcr.io/k8s-staging-external-dns/external-dnsSecurity Context Issues
# Ensure compatible security context
securityContext:
runAsNonRoot: true
runAsUser: 65534
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]Debug Mode
Enable debug logging for detailed troubleshooting:
logLevel: debug
logFormat: json
# Or via args
extraArgs:
log-level: debugWarning: Debug logging is verbose. Return to info or warning after troubleshooting.
Dry Run Mode
Test changes without applying:
extraArgs:
dry-run: trueCheck logs to see what changes would be made:
kubectl logs -n external-dns deployment/external-dns | grep "would have"Validation Checklist
Pre-deployment
- [ ] DNS provider credentials are valid
- [ ] Appropriate RBAC permissions assigned
- [ ]
txtOwnerIdis unique for this cluster - [ ]
domainFiltersincludes all target domains - [ ] Source types (service/ingress) are configured
- [ ] Namespace is created
Post-deployment
- [ ] Pods are Running
- [ ] No error logs
- [ ] Test record created successfully
- [ ] TXT ownership record exists
- [ ] DNS resolution works
- [ ] Metrics endpoint accessible (port 7979)
Test Record
Deploy a test service:
apiVersion: v1
kind: Service
metadata:
name: external-dns-test
namespace: default
annotations:
external-dns.alpha.kubernetes.io/hostname: test.example.com
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 80
selector:
app: nginx
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-test
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80Verify:
# Wait for LoadBalancer IP
kubectl get svc external-dns-test -w
# Check external-dns logs
kubectl logs -n external-dns deployment/external-dns | grep test.example.com
# Verify DNS record
dig test.example.comGetting Help
Logs to Collect
# External-DNS logs
kubectl logs -n external-dns deployment/external-dns --tail=500 > external-dns.log
# Pod description
kubectl describe pod -n external-dns -l app.kubernetes.io/name=external-dns > pod-describe.txt
# Configuration
kubectl get deployment external-dns -n external-dns -o yaml > deployment.yaml
# Events
kubectl get events -n external-dns --sort-by='.lastTimestamp' > events.txtResources
Quick Fixes Reference
| Problem | Quick Fix |
|---|---|
| No records created | Check domainFilters and sources |
| Auth failure (Azure) | Verify Workload Identity labels and role assignment |
| Auth failure (Cloudflare) | Test API token with curl |
| Rate limited | Increase interval to 10m+ |
| TXT conflicts | Ensure unique txtOwnerId per cluster |
| Records not deleted | Expected with policy: upsert-only |
| Pods crashing | Check resource limits and security context |