Skip to content

Split Horizon DNS

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.

Overview

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.

Use Cases

  • Internal/External separation: Internal DNS points to private IPs (ClusterIP), external DNS points to public Load Balancer IPs
  • Multiple DNS providers: Route different services to different DNS providers (e.g., internal to CoreDNS, external to Route53)
  • Geographic split: Different DNS records for different regions

Configuration

Basic Split Horizon Setup

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

Service with Both Annotations

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 DNS (Route53 Private Zone internal.company.com): myapp.internal.company.com → 10.0.1.50
  • External DNS (Route53 Public Zone company.com): myapp.company.com → 203.0.113.10 (LoadBalancer IP)

Helm Chart Configuration

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

Advanced Examples

Three-Way Split (Internal / DMZ / External)

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

Different Providers Per Instance

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

Important Notes

  1. Annotation prefix must end with / - The validation will fail if the prefix doesn’t end with a forward slash.
  2. Backward compatibility - If you don’t specify --annotation-prefix, the default external-dns.alpha.kubernetes.io/ is used, maintaining full backward compatibility.
  3. All annotations use the same prefix - When you set a custom prefix, ALL external-dns annotations (hostname, ttl, target, cloudflare-proxied, etc.) must use that prefix.
  4. TXT ownership records - Each instance should have a unique --txt-owner-id to avoid conflicts in ownership tracking.
  5. Provider-specific annotations - Provider-specific annotations (like 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

Troubleshooting

Both instances processing the same resources

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

Validation error: “annotation-prefix must end with ‘/’”

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

Provider-specific annotations not working

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"

See Also