ExternalDNS watches Kubernetes Services and Ingresses and keeps matching records in an external DNS provider, so a new LoadBalancer Service or Ingress host gets its A record without anyone touching the DNS panel. In this tutorial you will install ExternalDNS with Helm, connect it to Cloudflare with a scoped API token, publish a record for a demo Service, and learn the ownership and policy settings that keep it from touching records it does not own. A PowerDNS configuration is included at the end for self-hosted DNS.
Prerequisites
To follow this guide you need:
- A Kubernetes cluster where
LoadBalancerServices receive an external IP (for example through MetalLB or a cloud load balancer), or an Ingress controller that publishes its address in the Ingress status. kubectlwith admin access and Helm 3 on your workstation.- A domain whose DNS zone is hosted in Cloudflare. This guide uses
example.com; replace it with your own domain. digon your workstation to verify records (packagednsutilson Ubuntu).
Step 1 - Creating a Cloudflare API token
ExternalDNS needs permission to read zones and edit DNS records. Use an API token restricted to the zones it manages instead of your global API key.
In the Cloudflare dashboard, go to My Profile > API Tokens > Create Token, start from the Edit zone DNS template and set:
- Permissions: Zone / Zone / Read and Zone / DNS / Edit.
- Zone Resources: Include / Specific zone / example.com.
Copy the token, then store it in a Kubernetes Secret in a dedicated namespace. Replace your_cloudflare_api_token with the value you copied:
kubectl create namespace external-dns
kubectl create secret generic cloudflare-api-token -n external-dns \
--from-literal=api-token=your_cloudflare_api_token
Step 2 - Installing ExternalDNS with Helm
Add the chart repository maintained by the Kubernetes SIG:
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update
Create a values file:
nano external-dns-values.yaml
provider:
name: cloudflare
env:
- name: CF_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-api-token
key: api-token
sources:
- service
- ingress
domainFilters:
- example.com
txtOwnerId: k8s-prod
policy: upsert-only
interval: 1m
logLevel: info
The settings that matter most:
| Setting | Purpose |
|---|---|
sources | Which Kubernetes objects ExternalDNS reads hostnames from. |
domainFilters | Only zones and names under these domains are managed. Everything else is ignored. |
txtOwnerId | ExternalDNS writes a TXT record next to every record it creates, containing this ID. It only updates or deletes records that carry its own ID. Use a unique value per cluster. |
policy | upsert-only creates and updates but never deletes. sync also deletes records when the Service or Ingress goes away. |
interval | How often a full reconciliation runs. Cloudflare rate limits API calls, so do not set this too low. |
Start with upsert-only while you confirm the behavior, then switch to sync in Step 5.
Install the chart:
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--values external-dns-values.yaml
Check that the pod is running and that it can talk to Cloudflare:
kubectl get pods -n external-dns
kubectl logs -n external-dns deploy/external-dns --tail=20
NAME READY STATUS RESTARTS AGE
external-dns-6d8c5f7b9c-p4w2k 1/1 Running 0 30s
In the logs you should see the configuration being printed and then All records are already up to date. An authentication error at this point means the token or its zone scope is wrong.
Step 3 - Publishing a record for a Service
ExternalDNS publishes a Service of type LoadBalancer when it carries the external-dns.alpha.kubernetes.io/hostname annotation. Create a demo web server:
kubectl create deployment web --image=nginx
Create a Service manifest for it:
nano web-service.yaml
apiVersion: v1
kind: Service
metadata:
name: web
annotations:
external-dns.alpha.kubernetes.io/hostname: web.example.com
external-dns.alpha.kubernetes.io/ttl: "300"
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 80
The hostname annotation accepts a comma-separated list if you want several names for the same Service. Apply it and wait for an external IP:
kubectl apply -f web-service.yaml
kubectl get service web
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web LoadBalancer 10.96.41.120 203.0.113.25 80:31544/TCP 20s
Within one interval, ExternalDNS creates the record. The logs show the change:
kubectl logs -n external-dns deploy/external-dns --tail=5
level=info msg="Changing record." action=CREATE record=web.example.com ttl=300 type=A zone=...
level=info msg="Changing record." action=CREATE record=a-web.example.com type=TXT zone=...
Verify it resolves:
dig +short web.example.com
203.0.113.25
By default, Cloudflare records are created as DNS only (not proxied). To proxy one record through Cloudflare, add the annotation external-dns.alpha.kubernetes.io/cloudflare-proxied: "true" to that Service or Ingress.
Step 4 - Publishing records from an Ingress
For Ingress resources you do not need a hostname annotation: ExternalDNS reads the hosts from spec.rules[].host and points them at the address in the Ingress status. A minimal example, assuming an Ingress class named nginx:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
Once kubectl get ingress web shows an address in the ADDRESS column, ExternalDNS creates app.example.com. If the column stays empty, the Ingress controller is not publishing its status and ExternalDNS has nothing to point the record at.
Step 5 - Enabling sync and limiting what is managed
With upsert-only, deleting the Service leaves a stale DNS record behind. Once you are confident the domainFilters and txtOwnerId are correct, switch to sync so records are removed together with their Service or Ingress. Edit external-dns-values.yaml:
policy: sync
Before applying it to a zone with existing records, preview what ExternalDNS would do by adding a dry run temporarily:
extraArgs:
- --dry-run
helm upgrade external-dns external-dns/external-dns \
--namespace external-dns \
--values external-dns-values.yaml
kubectl logs -n external-dns deploy/external-dns --tail=30
In dry run mode the logs list the planned changes without calling the API. If no unexpected DELETE appears, remove the extraArgs block and run the same helm upgrade again.
Because ExternalDNS only deletes records whose TXT record carries its own txtOwnerId, records you created by hand in the zone are left alone even with sync.
If you only want to publish some Services, use an annotation filter so ExternalDNS ignores everything without an explicit opt-in:
extraArgs:
- --annotation-filter=external-dns.alpha.kubernetes.io/enabled in (true)
Then add external-dns.alpha.kubernetes.io/enabled: "true" to the Services and Ingresses that should get records.
Test the full lifecycle by deleting the demo Service:
kubectl delete service web
After one interval, dig +short web.example.com returns nothing.
Using PowerDNS instead of Cloudflare
If you run your own authoritative DNS with PowerDNS, enable its HTTP API (api=yes, api-key=... and webserver=yes in pdns.conf) and make it reachable from the cluster. Store the API key in a Secret:
kubectl create secret generic pdns-api-key -n external-dns \
--from-literal=api-key=your_pdns_api_key
Use these values instead of the Cloudflare block. ExternalDNS reads any command-line flag from an environment variable named EXTERNAL_DNS_<FLAG>, which keeps the key out of the pod arguments:
provider:
name: pdns
env:
- name: EXTERNAL_DNS_PDNS_API_KEY
valueFrom:
secretKeyRef:
name: pdns-api-key
key: api-key
extraArgs:
- --pdns-server=http://pdns.example.internal:8081
sources:
- service
- ingress
domainFilters:
- example.com
txtOwnerId: k8s-prod
policy: upsert-only
Replace pdns.example.internal:8081 with the address of your PowerDNS API. The zone must already exist in PowerDNS; ExternalDNS manages records, not zones.
Troubleshooting
No record is created. Check the logs with kubectl logs -n external-dns deploy/external-dns. The usual causes are a hostname outside domainFilters, a Service of type ClusterIP (it has no external address to publish), or a LoadBalancer Service still waiting for its external IP.
403 or authentication errors from Cloudflare. The token lacks Zone Read or DNS Edit, or is not scoped to the zone you are using.
A record exists but ExternalDNS will not update it. The record was created by hand or by another cluster with a different txtOwnerId, so ExternalDNS treats it as foreign. Delete it manually and let ExternalDNS recreate it.
Two clusters overwrite each other's records. Each ExternalDNS instance writing to the same zone needs its own txtOwnerId.
Conclusion
ExternalDNS now keeps your Cloudflare (or PowerDNS) zone in step with the Services and Ingresses in your cluster, and the TXT ownership records stop it from touching anything it did not create. As next steps, pair it with cert-manager so the same hostnames also get TLS certificates, move to policy: sync once you trust the configuration, and run one instance per zone or provider if you manage both public and internal DNS.
