Integrating Kubernetes External-DNS with BIND for Automatic DNS Management
External-DNS is a Kubernetes controller that automatically creates and manages DNS records based on Kubernetes resources like Services and Ingresses. When combined with BIND using the RFC2136 (dynamic DNS) provider, you get fully automated DNS management for your Kubernetes workloads without relying on cloud DNS providers.
How External-DNS Works
External-DNS watches Kubernetes resources (Services, Ingresses, Gateway API resources) and synchronizes DNS records with an external DNS provider. For BIND, it uses the RFC2136 protocol - the same dynamic DNS update mechanism we covered in the previous post.
The workflow:
- You deploy a Service or Ingress with DNS annotations
- External-DNS detects the resource and its annotations
- External-DNS sends DNS UPDATE requests to your BIND server
- BIND creates or updates the DNS records
- When resources are deleted, External-DNS removes the corresponding records
Prerequisites
BIND Configuration
Your BIND server needs to accept dynamic updates. Generate a TSIG key for External-DNS:
tsig-keygen -a hmac-sha256 external-dns-key > /etc/named/keys/external-dns.key
Configure BIND to accept updates:
// /etc/named.conf
include "/etc/named/keys/external-dns.key";
zone "k8s.example.com" {
type primary;
file "/var/named/dynamic/k8s.example.com.zone";
update-policy {
grant external-dns-key zonesub ANY;
};
};
// If managing reverse DNS
zone "1.168.192.in-addr.arpa" {
type primary;
file "/var/named/dynamic/192.168.1.rev";
update-policy {
grant external-dns-key zonesub PTR;
};
};
Create the initial zone file:
; /var/named/dynamic/k8s.example.com.zone
$TTL 300
@ IN SOA ns1.example.com. admin.example.com. (
2024010101 ; Serial
3600 ; Refresh
1800 ; Retry
604800 ; Expire
300 ) ; Minimum TTL
IN NS ns1.example.com.
Deploying External-DNS
Create TSIG Secret
First, create a Kubernetes secret containing the TSIG key:
# Extract the secret from the key file
TSIG_SECRET=$(grep secret /etc/named/keys/external-dns.key | cut -d '"' -f2)
kubectl create namespace external-dns
kubectl create secret generic external-dns-tsig \
--namespace external-dns \
--from-literal=tsig-secret="$TSIG_SECRET"
Or as a YAML manifest:
apiVersion: v1
kind: Secret
metadata:
name: external-dns-tsig
namespace: external-dns
type: Opaque
stringData:
tsig-secret: "your-base64-tsig-secret-here"
RBAC Configuration
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
namespace: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: external-dns
rules:
- apiGroups: [""]
resources: ["services", "endpoints", "pods"]
verbs: ["get", "watch", "list"]
- apiGroups: ["extensions", "networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "watch", "list"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["list", "watch"]
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["gateways", "httproutes", "tlsroutes", "tcproutes", "udproutes"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: external-dns-viewer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: external-dns
subjects:
- kind: ServiceAccount
name: external-dns
namespace: external-dns
External-DNS Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
namespace: external-dns
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: external-dns
template:
metadata:
labels:
app: external-dns
spec:
serviceAccountName: external-dns
containers:
- name: external-dns
image: registry.k8s.io/external-dns/external-dns:v0.14.0
args:
- --source=service
- --source=ingress
- --provider=rfc2136
- --rfc2136-host=192.168.1.10
- --rfc2136-port=53
- --rfc2136-zone=k8s.example.com
- --rfc2136-tsig-keyname=external-dns-key
- --rfc2136-tsig-secret-alg=hmac-sha256
- --rfc2136-tsig-axfr
- --domain-filter=k8s.example.com
- --policy=sync
- --registry=txt
- --txt-owner-id=k8s-cluster
- --interval=30s
- --log-level=info
env:
- name: RFC2136_TSIG_SECRET
valueFrom:
secretKeyRef:
name: external-dns-tsig
key: tsig-secret
Configuration Options
Key Arguments Explained
Sources:
--source=service- Watch Kubernetes Services--source=ingress- Watch Ingress resources--source=gateway-httproute- Watch Gateway API HTTPRoutes
Provider Settings:
--provider=rfc2136- Use RFC2136 dynamic DNS--rfc2136-host- BIND server address--rfc2136-port- DNS port (default 53)--rfc2136-zone- Zone to manage--rfc2136-tsig-keyname- TSIG key name (must match BIND config)--rfc2136-tsig-secret-alg- Algorithm (hmac-sha256)--rfc2136-tsig-axfr- Allow zone transfers to read existing records
Filtering:
--domain-filter- Only manage records in this domain--namespace- Only watch resources in specific namespace--annotation-filter- Only manage resources with specific annotations
Policies:
--policy=sync- Full sync: create, update, delete records--policy=upsert-only- Only create/update, never delete--policy=create-only- Only create new records
Registry:
--registry=txt- Use TXT records to track ownership--txt-owner-id- Unique identifier for this External-DNS instance
Multiple Zones
To manage multiple zones, deploy multiple External-DNS instances or use comma-separated zones:
args:
- --rfc2136-zone=k8s.example.com
- --rfc2136-zone=apps.example.com
- --domain-filter=k8s.example.com
- --domain-filter=apps.example.com
Creating DNS Records
Via Services
Use the external-dns.alpha.kubernetes.io/hostname annotation:
apiVersion: v1
kind: Service
metadata:
name: my-app
annotations:
external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 8080
selector:
app: my-app
For multiple hostnames:
metadata:
annotations:
external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com,app.k8s.example.com
Set custom TTL:
metadata:
annotations:
external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com
external-dns.alpha.kubernetes.io/ttl: "60"
Via Ingress
External-DNS automatically uses Ingress hostnames:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app-ingress
spec:
ingressClassName: nginx
rules:
- host: myapp.k8s.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app
port:
number: 80
The Ingress controller must populate the status.loadBalancer.ingress field for External-DNS to determine the target IP.
Via Gateway API
With Gateway API, External-DNS reads hostnames from HTTPRoute resources:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app-route
spec:
parentRefs:
- name: my-gateway
hostnames:
- myapp.k8s.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: my-app
port: 80
Record Types
A Records
Created automatically for LoadBalancer Services with external IPs:
apiVersion: v1
kind: Service
metadata:
name: my-app
annotations:
external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com
spec:
type: LoadBalancer
loadBalancerIP: 192.168.1.100 # Optional: request specific IP
ports:
- port: 80
selector:
app: my-app
CNAME Records
For services behind cloud load balancers that return hostnames:
metadata:
annotations:
external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com
external-dns.alpha.kubernetes.io/target: elb.amazonaws.com
TXT Records
External-DNS creates TXT records to track ownership:
myapp.k8s.example.com. 300 IN A 192.168.1.100
myapp.k8s.example.com. 300 IN TXT "heritage=external-dns,external-dns/owner=k8s-cluster,external-dns/resource=service/default/my-app"
This prevents External-DNS from accidentally modifying records it didn't create.
Advanced Configuration
Multiple Clusters
When multiple clusters share a zone, use different owner IDs:
# Cluster 1
args:
- --txt-owner-id=cluster-prod
- --txt-prefix=_edns-prod-
# Cluster 2
args:
- --txt-owner-id=cluster-staging
- --txt-prefix=_edns-staging-
Filtering by Annotation
Only manage resources with specific annotations:
args:
- --annotation-filter=external-dns.alpha.kubernetes.io/enabled=true
Then annotate resources explicitly:
metadata:
annotations:
external-dns.alpha.kubernetes.io/enabled: "true"
external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com
Namespace Filtering
Limit External-DNS to specific namespaces:
args:
- --namespace=production
- --namespace=staging
Internal vs External Records
Run two External-DNS instances for split-horizon DNS:
# Internal DNS (for internal services)
- --source=service
- --rfc2136-host=internal-dns.example.com
- --rfc2136-zone=internal.k8s.example.com
- --annotation-filter=external-dns.alpha.kubernetes.io/internal=true
# External DNS (for public services)
- --source=ingress
- --rfc2136-host=external-dns.example.com
- --rfc2136-zone=k8s.example.com
- --annotation-filter=external-dns.alpha.kubernetes.io/internal!=true
Helm Installation
For production deployments, use the Helm chart:
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--create-namespace \
--set provider=rfc2136 \
--set rfc2136.host=192.168.1.10 \
--set rfc2136.port=53 \
--set rfc2136.zone=k8s.example.com \
--set rfc2136.tsigKeyname=external-dns-key \
--set rfc2136.tsigSecretAlg=hmac-sha256 \
--set rfc2136.tsigAxfr=true \
--set domainFilters[0]=k8s.example.com \
--set policy=sync \
--set txtOwnerId=k8s-cluster \
--set extraEnv[0].name=RFC2136_TSIG_SECRET \
--set-string extraEnv[0].valueFrom.secretKeyRef.name=external-dns-tsig \
--set-string extraEnv[0].valueFrom.secretKeyRef.key=tsig-secret
Troubleshooting
Check External-DNS Logs
kubectl logs -n external-dns deployment/external-dns -f
Look for:
All records are already up to date- Normal operationUpdating record- Changes being madeTSIG error- Authentication issuesREFUSED- BIND rejecting updates
Verify BIND Received Updates
Enable update logging in BIND:
logging {
channel update_log {
file "/var/log/named/update.log" versions 5 size 10m;
severity debug;
print-time yes;
};
category update { update_log; };
category update-security { update_log; };
};
Test Connectivity
From the External-DNS pod:
kubectl exec -n external-dns deployment/external-dns -- nslookup k8s.example.com 192.168.1.10
Manual DNS Test
Test dynamic updates manually:
nsupdate -v << EOF
server 192.168.1.10
key external-dns-key <secret>
zone k8s.example.com
update add test.k8s.example.com 300 A 192.168.1.99
send
EOF
Common Issues
Records not created:
- Check
--domain-filtermatches your hostnames - Verify Service has
status.loadBalancer.ingresspopulated - Check annotation spelling
TSIG authentication failed:
- Verify key name matches exactly
- Check algorithm matches (hmac-sha256)
- Ensure secret is correctly encoded in the Kubernetes secret
Records deleted unexpectedly:
- Check
--policysetting (useupsert-onlyto prevent deletions) - Verify TXT ownership records exist
- Check
--txt-owner-idmatches
Security Considerations
Limit Zone Access
Restrict External-DNS to specific subdomains:
update-policy {
grant external-dns-key subdomain k8s.example.com ANY;
};
Network Policies
Restrict External-DNS network access:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: external-dns
namespace: external-dns
spec:
podSelector:
matchLabels:
app: external-dns
policyTypes:
- Egress
egress:
# Allow DNS to BIND server
- to:
- ipBlock:
cidr: 192.168.1.10/32
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Allow API server access
- to:
- namespaceSelector: {}
ports:
- protocol: TCP
port: 443
Conclusion
External-DNS with BIND provides cloud-agnostic, automated DNS management for Kubernetes. By leveraging RFC2136 dynamic updates, you maintain full control over your DNS infrastructure while enjoying the convenience of automatic record management.
The next post will cover Response Policy Zones (RPZ) for implementing DNS-based security filtering and blocking.