docs/advanced/split-horizon.md
Split horizon DNS allows you to serve different DNS responses based on the client's location - internal clients receive private IPs while external clients receive public IPs. External-DNS supports split horizon DNS by running multiple instances with different annotation prefixes.
By default, all external-dns instances use the same annotation prefix: external-dns.alpha.kubernetes.io/. This means all instances process the same annotations. To enable split horizon DNS, you can configure each instance to use a different annotation prefix via the --annotation-prefix flag.
Internal DNS Instance:
external-dns \
--annotation-prefix=internal.company.io/ \
--source=service \
--source=ingress \
--provider=aws \
--aws-zone-type=private \
--domain-filter=internal.company.com \
--txt-owner-id=internal-dns
External DNS Instance:
external-dns \
--annotation-prefix=external-dns.alpha.kubernetes.io/ \ # default, can be omitted
--source=service \
--source=ingress \
--provider=aws \
--aws-zone-type=public \
--domain-filter=company.com \
--txt-owner-id=external-dns
apiVersion: v1
kind: Service
metadata:
name: myapp
annotations:
# Internal DNS reads this
internal.company.io/hostname: myapp.internal.company.com
internal.company.io/ttl: "300"
internal.company.io/target: 10.0.1.50 # Private IP
# External DNS reads this
external-dns.alpha.kubernetes.io/hostname: myapp.company.com
external-dns.alpha.kubernetes.io/ttl: "60"
# No target = uses LoadBalancer IP automatically
spec:
type: LoadBalancer
clusterIP: 10.0.1.50
ports:
- port: 80
targetPort: 8080
selector:
app: myapp
Result:
internal.company.com): myapp.internal.company.com → 10.0.1.50company.com): myapp.company.com → 203.0.113.10 (LoadBalancer IP)You can use the Helm chart to deploy multiple instances:
values-internal.yaml:
annotationPrefix: "internal.company.io/"
provider:
name: aws
aws:
zoneType: private
domainFilters:
- internal.company.com
txtOwnerId: internal-dns
sources:
- service
- ingress
values-external.yaml:
# annotationPrefix defaults to "external-dns.alpha.kubernetes.io/"
# can be omitted or set explicitly:
# annotationPrefix: "external-dns.alpha.kubernetes.io/"
provider:
name: aws
aws:
zoneType: public
domainFilters:
- company.com
txtOwnerId: external-dns
sources:
- service
- ingress
Deploy:
# Internal instance
helm install external-dns-internal external-dns/external-dns \
--namespace external-dns-internal \
--create-namespace \
--values values-internal.yaml
# External instance
helm install external-dns-external external-dns/external-dns \
--namespace external-dns-external \
--create-namespace \
--values values-external.yaml
apiVersion: v1
kind: Service
metadata:
name: api
annotations:
# Internal (private network only)
internal.company.io/hostname: api.internal.company.com
internal.company.io/ttl: "300"
# DMZ (accessible from office network)
dmz.company.io/hostname: api.dmz.company.com
dmz.company.io/ttl: "120"
# External (public internet)
external-dns.alpha.kubernetes.io/hostname: api.company.com
external-dns.alpha.kubernetes.io/ttl: "60"
external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"
spec:
type: LoadBalancer
# ...
Deploy three instances:
# Internal
--annotation-prefix=internal.company.io/ --provider=aws --aws-zone-type=private
# DMZ
--annotation-prefix=dmz.company.io/ --provider=aws --aws-zone-type=private
# External
--annotation-prefix=external-dns.alpha.kubernetes.io/ --provider=cloudflare
apiVersion: v1
kind: Service
metadata:
name: webapp
annotations:
# Route53 for AWS internal
aws.company.io/hostname: webapp.aws.company.com
aws.company.io/aws-alias: "true"
# Cloudflare for public
cf.company.io/hostname: webapp.company.com
cf.company.io/cloudflare-proxied: "true"
spec:
type: LoadBalancer
# ...
Deploy:
# AWS instance
--annotation-prefix=aws.company.io/ --provider=aws
# Cloudflare instance
--annotation-prefix=cf.company.io/ --provider=cloudflare
/ - The validation will fail if the prefix doesn't end with a forward slash.--annotation-prefix, the default external-dns.alpha.kubernetes.io/ is used, maintaining full backward compatibility.--txt-owner-id to avoid conflicts in ownership tracking.cloudflare-proxied, aws-alias) also use the custom prefix:custom.io/hostname: example.com
custom.io/cloudflare-proxied: "true" # NOT external-dns.alpha.kubernetes.io/cloudflare-proxied
Problem: Both internal and external instances are creating records for the same service.
Solution: Make sure you're using different annotation prefixes and that your services have the correct annotations:
# ✅ Correct - different prefixes
internal.company.io/hostname: internal.example.com
external-dns.alpha.kubernetes.io/hostname: example.com
# ❌ Wrong - same prefix
external-dns.alpha.kubernetes.io/hostname: internal.example.com
external-dns.alpha.kubernetes.io/hostname: example.com # Second one overwrites first
Problem: The annotation prefix doesn't end with a forward slash.
Solution: Always end your custom prefix with /:
# ✅ Correct
--annotation-prefix=custom.io/
# ❌ Wrong
--annotation-prefix=custom.io
Problem: Cloudflare/AWS-specific annotations are not being applied.
Solution: Provider-specific annotations must use the same prefix as the hostname:
# If using custom prefix
custom.io/hostname: example.com
custom.io/cloudflare-proxied: "true"
custom.io/ttl: "60"