ExternalDNS on Kubernetes: Automatically Sync DNS Records to Cloudflare and Route53

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

The DNS Problem Nobody Talks About

You have a Kubernetes cluster running beautifully. Ingress controllers are deployed, services are up, and pods are healthy. Then you realize — you still have to manually go into Cloudflare or Route53 to create a DNS record every single time you deploy something new. If your LoadBalancer IP changes (and it does, especially in managed clusters), you have to update that record again.

The root cause is that Kubernetes has no native mechanism to talk to external DNS providers. Your cluster knows about IPs and hostnames, but it has no idea that Cloudflare or Route53 even exists. This gap is exactly what ExternalDNS was built to fill.

ExternalDNS watches your Kubernetes resources — specifically Ingress objects and Services of type LoadBalancer — and synchronizes the hostnames you define there directly to your DNS provider. Create an Ingress with host app.example.com, and ExternalDNS automatically creates an A record pointing to your load balancer IP. Delete that Ingress, and the DNS record disappears with it.

I have applied this approach in production environments running on both EKS and GKE, and the results have been consistently stable — no more stale DNS records, no manual updates, and significantly less toil for the team.

Installation

Prerequisites

Before installing ExternalDNS, make sure you have:

  • A running Kubernetes cluster (1.19+)
  • kubectl configured with cluster access
  • A DNS provider account — Cloudflare or AWS Route53
  • Helm 3 installed (recommended installation method)

Install with Helm

The cleanest way to get ExternalDNS running is via the Bitnami Helm chart. Add the repo and update:

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

You will customize the installation based on your DNS provider. The sections below walk through Cloudflare and Route53 separately — pick the one that applies to you.

Configuration

Option A: Cloudflare

First, generate an API token in your Cloudflare dashboard. Go to My Profile → API Tokens → Create Token and assign these permissions:

  • Zone → DNS → Edit
  • Zone → Zone → Read

Scope the token to the specific zone (domain) you want ExternalDNS to manage. Avoid using the Global API Key — a scoped token is far safer in production and limits blast radius if credentials leak.

Create a Kubernetes secret with your Cloudflare token:

kubectl create namespace external-dns

kubectl create secret generic cloudflare-api-token \
  --from-literal=apiToken=YOUR_CLOUDFLARE_API_TOKEN \
  -n external-dns

Create a values-cloudflare.yaml file with the ExternalDNS configuration:

provider: cloudflare

cloudflare:
  secretName: cloudflare-api-token
  proxied: false  # Set true if you want Cloudflare proxy (orange cloud)

domainFilters:
  - example.com  # Replace with your actual domain

policy: sync  # "sync" = create + delete, "upsert-only" = only create/update

sources:
  - ingress
  - service

txtOwnerId: k8s-cluster-prod  # Unique identifier for this cluster

logLevel: info

Install ExternalDNS:

helm install external-dns bitnami/external-dns \
  -n external-dns \
  -f values-cloudflare.yaml

Option B: AWS Route53

For Route53, ExternalDNS needs IAM permissions to manage DNS records. On EKS, the preferred approach is IAM Roles for Service Accounts (IRSA). For other setups, a dedicated IAM user with an access key works fine.

Create an IAM policy document and attach it to your IAM user or role:

{
  "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": ["*"]
    }
  ]
}

Store the IAM credentials as a Kubernetes secret:

kubectl create namespace external-dns

kubectl create secret generic aws-credentials \
  --from-literal=access-key-id=YOUR_AWS_ACCESS_KEY \
  --from-literal=secret-access-key=YOUR_AWS_SECRET_KEY \
  -n external-dns

Create values-route53.yaml:

provider: aws

aws:
  credentials:
    secretKey: aws-credentials
    accessKey: access-key-id
    secretAccessKey: secret-access-key
  region: us-east-1  # Your AWS region
  zoneType: public    # "public" or "private"

domainFilters:
  - example.com

policy: sync

sources:
  - ingress
  - service

txtOwnerId: k8s-cluster-prod

logLevel: info

Install ExternalDNS:

helm install external-dns bitnami/external-dns \
  -n external-dns \
  -f values-route53.yaml

Annotating Your Ingress and Services

ExternalDNS reads hostnames from your Kubernetes resources automatically. For Ingress objects, it picks up the spec.rules[].host field without any extra configuration. For LoadBalancer services, add an annotation to declare the DNS name:

# Ingress — ExternalDNS reads the host field automatically
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app-ingress
  namespace: default
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-app
                port:
                  number: 80
# LoadBalancer Service with DNS annotation
apiVersion: v1
kind: Service
metadata:
  name: my-service
  namespace: default
  annotations:
    external-dns.alpha.kubernetes.io/hostname: service.example.com
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 8080
  selector:
    app: my-app

One detail worth understanding: ExternalDNS creates TXT records alongside each DNS entry. These TXT records act as ownership markers — they are how ExternalDNS knows which records it manages versus records created by someone else. The txtOwnerId value is embedded in these TXT records. If you run multiple clusters pointing at the same DNS zone, each cluster must have a unique txtOwnerId to avoid conflicts.

Verification & Monitoring

Check the Deployment

Confirm ExternalDNS is running and healthy:

kubectl get pods -n external-dns
kubectl logs -n external-dns -l app.kubernetes.io/name=external-dns --tail=50

When ExternalDNS processes an Ingress or Service, you will see log entries like this:

level=info msg="Desired change: CREATE app.example.com A [203.0.113.42]"
level=info msg="Desired change: CREATE app.example.com TXT [\"heritage=external-dns,...\"]"
level=info msg="2 record(s) in zone example.com. were successfully updated"

Verify DNS Records

Use dig to confirm records appear in DNS:

# Check A record
dig app.example.com +short

# Check TXT ownership record
dig TXT "externaldns-app.example.com" +short

Changes typically propagate within one to two minutes. ExternalDNS runs a sync loop every 60 seconds by default. You can tune this with the interval setting in your Helm values if you need faster reconciliation during development or slower to reduce API calls in production.

Troubleshooting Common Issues

If records are not being created, check these first:

  • Wrong domainFilters: ExternalDNS skips any hostname that does not match the domainFilters list. Verify your Ingress host matches the filter exactly.
  • Missing permissions: For Route53, confirm the IAM policy is attached to the correct user or role. For Cloudflare, verify the API token scope includes the correct zone.
  • Policy set to upsert-only: With policy: upsert-only, ExternalDNS will not delete records when you remove an Ingress. Switch to policy: sync for full lifecycle management.
  • Ingress not assigned an external IP: ExternalDNS waits for an IP in status.loadBalancer.ingress. If your Ingress controller has not assigned one yet, ExternalDNS has nothing to work with — check your Ingress controller first.

Prometheus Metrics

ExternalDNS exposes Prometheus metrics on port 7979. If Prometheus runs in your cluster, enable scraping by adding pod annotations to the Helm values:

podAnnotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "7979"

Key metrics to watch are external_dns_controller_last_sync_timestamp_seconds (detect stalled sync loops) and external_dns_registry_endpoints_total (track how many records ExternalDNS is managing). A simple alert rule — firing when the last sync timestamp is more than five minutes old — catches credential issues or DNS provider outages before they affect users.

Running ExternalDNS has removed an entire category of operational toil from my workflow. No more “why is this domain not resolving” incidents caused by someone forgetting to update a DNS record after a deployment. The cluster manages its own DNS, and the team can stay focused on things that actually matter.

Share: