external-dns synchronizes DNS records with your Kubernetes cluster. Whenever a Service or Ingress is provisioned with a LoadBalancer address, external-dns creates or updates the corresponding DNS records at your DNS provider eliminating manual record maintenance when IPs change. It is not part of the core platform; you install and manage it as a standard cluster workload. **Read this guide if you are:** - Connecting Kubernetes Services or Ingresses to public DNS providers like Cloudflare, AWS Route 53, or Hetzner DNS (see [Install it with your provider](#install-it-with-your-provider)). - Automating DNS record creation and updates from Service annotations and Ingress host rules (see [Annotate what to publish](#annotate-what-to-publish)). - Publishing dual-stack `A` and `AAAA` records for Hetzner Load Balancer IPv4 and IPv6 endpoints (see [Publish A and AAAA together](#publish-a-and-aaaa-together)). - Isolating DNS management across multiple clusters or tenants using TXT record ownership and domain filters (see [Ownership and multi-tenant safety](#ownership-and-multi-tenant-safety)). ## Install it with your provider To install external-dns, add the official Helm chart repository and create a Kubernetes Secret containing your DNS provider credentials. Ensure your provider token or IAM policy is strictly scoped to only the specific DNS zones managed by this cluster. ```console $ helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/ ``` Store a scoped Cloudflare API token, then install pointed at the `cloudflare` provider: ```console $ kubectl create namespace external-dns $ kubectl create secret generic cloudflare-api-token -n external-dns \ --from-literal=token= $ helm install external-dns external-dns/external-dns \ --namespace external-dns \ --set provider=cloudflare \ --set 'env[0].name=CF_API_TOKEN' \ --set 'env[0].valueFrom.secretKeyRef.name=cloudflare-api-token' \ --set 'env[0].valueFrom.secretKeyRef.key=token' ``` Create the token with edit rights on only the DNS zones you delegate to it. Give external-dns an AWS access key, and scope its IAM policy to `ChangeResourceRecordSets` on the hosted zones it manages: ```console $ kubectl create namespace external-dns $ kubectl create secret generic route53-credentials -n external-dns \ --from-literal=aws_access_key_id= \ --from-literal=aws_secret_access_key= $ helm install external-dns external-dns/external-dns \ --namespace external-dns \ --set provider=aws \ --set 'env[0].name=AWS_ACCESS_KEY_ID' \ --set 'env[0].valueFrom.secretKeyRef.name=route53-credentials' \ --set 'env[0].valueFrom.secretKeyRef.key=aws_access_key_id' \ --set 'env[1].name=AWS_SECRET_ACCESS_KEY' \ --set 'env[1].valueFrom.secretKeyRef.name=route53-credentials' \ --set 'env[1].valueFrom.secretKeyRef.key=aws_secret_access_key' ``` Hetzner DNS is not built into external-dns; it runs through the webhook provider. Install external-dns with the Hetzner webhook sidecar and give the sidecar a Hetzner DNS API token: ```console $ kubectl create namespace external-dns $ kubectl create secret generic hetzner-dns-token -n external-dns \ --from-literal=api-key= $ helm install external-dns external-dns/external-dns \ --namespace external-dns \ --set provider.name=webhook \ --set provider.webhook.image.repository=ghcr.io/mconfalonieri/external-dns-hetzner-webhook \ --set 'provider.webhook.env[0].name=HETZNER_API_KEY' \ --set 'provider.webhook.env[0].valueFrom.secretKeyRef.name=hetzner-dns-token' \ --set 'provider.webhook.env[0].valueFrom.secretKeyRef.key=api-key' ``` Create the token in the Hetzner DNS Console, scoped to your DNS zones. ## Annotate what to publish external-dns reads a hostname annotation from your Services and Ingresses and publishes a record for it: ```yaml metadata: annotations: external-dns.alpha.kubernetes.io/hostname: app.example.com ``` For an `Ingress`, external-dns can also read the `host` rules directly, so the annotation is optional there. ## Publish A and AAAA together Every Hetzner load balancer receives a public IPv4 and IPv6 address, so publish both an `A` and an `AAAA` record to serve IPv6 clients. external-dns creates both when it sees both addresses in `status.loadBalancer.ingress`. See [Serve IPv6 clients](/docs/hetzner/apalla/network/load-balancing/serve-ipv6) for why the IPv6 side needs no change inside the cluster. ## Ownership and multi-tenant safety To track records and prevent accidental overwrites, external-dns creates a companion `TXT` registry record alongside every DNS entry it provisions. The record includes a configurable owner identifier (`--txt-owner-id`), ensuring controllers in different clusters or environments only modify their own entries. Always assign a unique owner ID per cluster. In multi-tenant or [agency environments](/docs/hetzner/apalla/security/multi-tenant-isolation-for-agencies) managing multiple customer domains, restrict each external-dns deployment with `--domain-filter` to permit modifications only on explicit zones. Deploying isolated controller instances per tenant prevents cross-tenant record tampering and ensures clean administrative boundaries. ### Run several clusters against one zone When a production and a staging cluster both publish into `example.com` zone, each external-dns has to tell its own records apart from its neighbor's. The owner id decides which records a cluster is willing to touch, and the TXT prefix keeps their registry records from colliding on the same name. Owner id alone carries you only as long as no cluster ever contends for a hostname another one already publishes, so set both before the first install. The chart exposes them as values: | Cluster | `txtOwnerId` | `txtPrefix` | `domainFilters` | | ---------- | ------------ | ----------- | --------------------- | | Production | `prod-eu` | `prod-` | `example.com` | | Staging | `staging-eu` | `staging-` | `staging.example.com` | ```yaml title="values-prod.yaml" txtOwnerId: prod-eu txtPrefix: prod- domainFilters: - example.com policy: sync ``` Where the topology allows it, give each cluster its own subdomain and filter on that, as the staging row does. The clusters then cannot contend for a record name at all, and the owner id becomes a second line of defense rather than the only one. If the zone also holds records external-dns did not create, set `policy: upsert-only` so it never deletes anything. > [!NOTE] > external-dns reconciles on an interval and every provider rate-limits its API. If records lag behind a change, that reflects the interval, not a failure. Do not set the interval so low that you hit the provider's rate limit. ## Where to go next - [Set up a Let's Encrypt ClusterIssuer](/docs/hetzner/apalla/network/dns-certs/lets-encrypt-clusterissuer) - [Configure a Hetzner load balancer](/docs/hetzner/apalla/network/load-balancing/configure-a-load-balancer)