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
- Go to Cloudflare Dashboard > My Profile > API Tokens
- Click "Create Token"
- Use "Custom token" template
- Configure permissions:
| Permission | Access Level | Required |
|---|---|---|
| Zone > Zone | Read | Yes |
| Zone > DNS | Edit | Yes |
- Zone Resources: Select "All zones" or specific zones
- Click "Continue to summary" > "Create Token"
- 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: true
Per-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: 80
When 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: 5000
Benefits:
- 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-dns
Multi-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_token
Different 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 error
Verify 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.com
List 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:
- Verify API token is correct in secret
- Check token has Zone:Read and DNS:Edit permissions
- Verify zone is accessible to the token (Zone Resources setting)
- 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:
Increase sync interval:
interval: "10m" # Increase from defaultIncrease records per page:
extraArgs: cloudflare-dns-records-per-page: 5000Use 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:
- Verify
cloudflare-proxied: truein extraArgs - Check per-record annotations aren't overriding
- Note: Some record types cannot be proxied (MX, TXT, etc.)
Issue: TXT Record Conflicts
Symptoms: TXT record ownership conflict in logs
Solutions:
- Ensure unique
txtOwnerIdper cluster - Delete orphaned TXT records manually
- Use different
txtPrefixif 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:
- Check
domainFiltersincludes your domain - Verify source type (service/ingress) is in
sources - Check dry-run is not enabled
- 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: 1
Security 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 dashboard
Minimal Permissions Token
Create token with minimal required permissions:
- Zone > Zone > Read - List zones
- Zone > DNS > Edit - Manage DNS records
- 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: 443
Monitoring
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_seconds
Alert 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 failure