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 LoadBalancer Services 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.
  • kubectl with 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.
  • dig on your workstation to verify records (package dnsutils on 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:

SettingPurpose
sourcesWhich Kubernetes objects ExternalDNS reads hostnames from.
domainFiltersOnly zones and names under these domains are managed. Everything else is ignored.
txtOwnerIdExternalDNS 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.
policyupsert-only creates and updates but never deletes. sync also deletes records when the Service or Ingress goes away.
intervalHow 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.