External DNS for Kubernetes Service Discovery
ExternalDNS automates DNS record management for Kubernetes by watching Services and Ingresses and creating or updating DNS records in external providers like Cloudflare, Route53, and PowerDNS. This eliminates manual DNS management and ensures that DNS records always reflect the current state of your Kubernetes cluster, making it essential for dynamic environments.
Prerequisites
- Kubernetes 1.19+
- Helm 3.x
- An external DNS provider account (Cloudflare, Route53, PowerDNS, etc.)
- API credentials for your DNS provider
- Ingress controller or LoadBalancer services with external IPs
Installing ExternalDNS
# Add ExternalDNS Helm chart
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update
# Basic installation (provider-specific config in next sections)
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--create-namespace \
--values external-dns-values.yaml
# Verify installation
kubectl -n external-dns get pods
kubectl -n external-dns logs deploy/external-dns -f
Cloudflare Configuration
# Create Cloudflare credentials secret
kubectl create secret generic cloudflare-api-token \
--from-literal=cloudflare_api_token=your-cloudflare-api-token \
-n external-dns
# Helm values for Cloudflare
cat > cloudflare-values.yaml <<EOF
provider:
name: cloudflare
env:
- name: CF_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-api-token
key: cloudflare_api_token
# Domains to manage
domainFilters:
- example.com
- internal.example.com
# Only manage records ExternalDNS creates (ownership system)
txtOwnerId: "k8s-cluster-production"
# Sync policy
policy: sync # 'sync' creates and deletes, 'upsert-only' only creates/updates
# Sources to watch
sources:
- service
- ingress
# Cloudflare-specific options
extraArgs:
- --cloudflare-proxied # Enable Cloudflare proxy for all records
# Remove the above if you don't want Cloudflare proxy
interval: 1m
logLevel: info
EOF
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--create-namespace \
--values cloudflare-values.yaml
# For per-record proxy control, use annotations instead of global flag
# See Source Annotations section
Route53 Configuration
# Create IAM policy for ExternalDNS (save as externaldns-policy.json)
cat > externaldns-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets"
],
"Resource": [
"arn:aws:route53:::hostedzone/*"
]
},
{
"Effect": "Allow",
"Action": [
"route53:ListHostedZones",
"route53:ListResourceRecordSets",
"route53:ListTagsForResource"
],
"Resource": ["*"]
}
]
}
EOF
aws iam create-policy \
--policy-name ExternalDNSPolicy \
--policy-document file://externaldns-policy.json
# Create IAM user (or use IRSA for EKS)
aws iam create-user --user-name externaldns
aws iam attach-user-policy \
--user-name externaldns \
--policy-arn arn:aws:iam::YOUR_ACCOUNT:policy/ExternalDNSPolicy
aws iam create-access-key --user-name externaldns
# Save the AccessKeyId and SecretAccessKey
# Create AWS credentials secret
kubectl create secret generic aws-credentials \
--from-literal=aws_access_key_id=AKIAIOSFODNN7EXAMPLE \
--from-literal=aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
-n external-dns
# Helm values for Route53
cat > route53-values.yaml <<EOF
provider:
name: aws
env:
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: aws-credentials
key: aws_access_key_id
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: aws-credentials
key: aws_secret_access_key
- name: AWS_DEFAULT_REGION
value: us-east-1
domainFilters:
- example.com
txtOwnerId: "k8s-prod-cluster"
policy: sync
sources:
- service
- ingress
extraArgs:
- --aws-zone-type=public # 'public', 'private', or '' for both
# Use Route53 evaluate target health
- --aws-sd-service-cleanup # Clean up old service discovery records
EOF
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--create-namespace \
--values route53-values.yaml
# For EKS with IRSA (recommended - no static credentials)
# Annotate the service account instead of creating credentials:
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--create-namespace \
--set provider.name=aws \
--set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=arn:aws:iam::ACCOUNT:role/ExternalDNSRole \
--set domainFilters[0]=example.com \
--set txtOwnerId=k8s-prod-cluster
PowerDNS Configuration
# Create PowerDNS API credentials secret
kubectl create secret generic pdns-api-key \
--from-literal=apiKey=your-pdns-api-key \
-n external-dns
cat > pdns-values.yaml <<EOF
provider:
name: pdns
env:
- name: PDNS_API_URL
value: http://pdns-api.example.com:8081
- name: PDNS_API_KEY
valueFrom:
secretKeyRef:
name: pdns-api-key
key: apiKey
- name: PDNS_SERVER_ID
value: localhost
domainFilters:
- internal.example.com
txtOwnerId: "k8s-internal"
policy: sync
sources:
- service
- ingress
EOF
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--create-namespace \
--values pdns-values.yaml
Source Annotations and Filtering
Control DNS record creation with annotations:
# Service with custom hostname annotation
cat > annotated-service.yaml <<EOF
apiVersion: v1
kind: Service
metadata:
name: webapp
namespace: production
annotations:
# Explicit hostname(s)
external-dns.alpha.kubernetes.io/hostname: "app.example.com,www.example.com"
# Custom TTL
external-dns.alpha.kubernetes.io/ttl: "60"
# Cloudflare-specific: enable/disable proxy per service
external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"
spec:
type: LoadBalancer
selector:
app: webapp
ports:
- port: 80
targetPort: 8080
EOF
kubectl apply -f annotated-service.yaml
# Ingress with automatic hostname from spec
cat > annotated-ingress.yaml <<EOF
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: webapp-ingress
namespace: production
annotations:
# ExternalDNS reads hostnames from spec.rules[].host automatically
# Override TTL
external-dns.alpha.kubernetes.io/ttl: "300"
# Exclude from DNS management
# external-dns.alpha.kubernetes.io/exclude: "true"
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webapp
port:
number: 80
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-server
port:
number: 8080
EOF
kubectl apply -f annotated-ingress.yaml
# Filter which services ExternalDNS manages using labels
# In values.yaml:
# annotationFilter: "external-dns.alpha.kubernetes.io/enabled=true"
# Then only annotate services you want managed:
kubectl annotate service webapp \
"external-dns.alpha.kubernetes.io/enabled=true" \
-n production
Ownership and Record Management
ExternalDNS uses TXT records to track ownership:
# Each DNS record gets a corresponding TXT ownership record
# Example: app.example.com A 1.2.3.4
# Owner: externaldns-app.example.com TXT "heritage=external-dns,external-dns/owner=k8s-prod-cluster"
# View records ExternalDNS manages (Cloudflare example)
# Check with provider's CLI or API
# For Route53, list records:
aws route53 list-resource-record-sets \
--hosted-zone-id YOUR_ZONE_ID \
--query "ResourceRecordSets[?Type=='TXT']"
# Safely transfer management to a new ExternalDNS instance
# Change txtOwnerId in new deployment
# Run with --policy=upsert-only first to avoid deleting records
# Dry run mode to preview changes
helm upgrade external-dns external-dns/external-dns \
--namespace external-dns \
--reuse-values \
--set extraArgs[0]=--dry-run
Multiple Provider Setup
Run multiple ExternalDNS instances for different providers:
# Install second ExternalDNS for internal DNS
cat > internal-dns-values.yaml <<EOF
nameOverride: external-dns-internal
provider:
name: pdns
env:
- name: PDNS_API_URL
value: http://pdns-internal:8081
- name: PDNS_API_KEY
valueFrom:
secretKeyRef:
name: pdns-internal-key
key: apiKey
domainFilters:
- internal.example.com
# Different owner ID to avoid conflicts
txtOwnerId: "k8s-internal-dns"
# Filter to only manage services with this annotation
annotationFilter: "dns.internal/managed=true"
sources:
- service
- ingress
EOF
helm install external-dns-internal external-dns/external-dns \
--namespace external-dns \
--create-namespace \
--values internal-dns-values.yaml
# Annotate services for internal DNS management
kubectl annotate service internal-api \
"dns.internal/managed=true" \
"external-dns.alpha.kubernetes.io/hostname=api.internal.example.com" \
-n production
Troubleshooting
DNS records not being created:
# Check ExternalDNS logs for errors
kubectl -n external-dns logs deploy/external-dns --tail=100
# Common issues:
# - Missing permissions on DNS provider
# - Domain not in domainFilters
# - Service type is ClusterIP (needs LoadBalancer or NodePort)
# Verify ExternalDNS can see the service
kubectl -n external-dns logs deploy/external-dns | grep "desired"
Records being deleted unexpectedly:
# Switch to upsert-only policy to prevent deletion
kubectl -n external-dns patch deploy/external-dns \
--type=json \
-p='[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--policy=upsert-only"}]'
# Check if multiple ExternalDNS instances have conflicting owner IDs
kubectl -n external-dns get deploy -o yaml | grep txtOwnerId
Cloudflare "too many requests" error:
# Add rate limiting args
helm upgrade external-dns external-dns/external-dns \
--namespace external-dns \
--reuse-values \
--set interval=5m # Increase polling interval
Route53 permission denied:
# Test IAM permissions
aws sts get-caller-identity
aws route53 list-hosted-zones
# Verify policy is attached
aws iam list-attached-user-policies --user-name externaldns
Conclusion
ExternalDNS eliminates DNS management toil by automatically synchronizing Kubernetes Services and Ingresses with your DNS provider. With support for all major providers, annotation-based control, and a safe ownership model using TXT records, it scales from simple single-cluster setups to complex multi-cluster, multi-provider configurations. Set policy: sync with careful domainFilters and txtOwnerId settings to maintain safe, predictable DNS automation.


