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.