Back to External Dns

FQDN Templating Guide

docs/advanced/fqdn-templating.md

0.21.021.9 KB
Original Source

FQDN Templating Guide

What is FQDN Templating?

FQDN templating is a feature that allows to dynamically construct Fully Qualified Domain Names (FQDNs) using a Go templating engine. Instead of relying solely on annotations or static names, you can use metadata from Kubernetes objects—such as service names, namespaces, and labels—to generate DNS records programmatically and dynamically.

This is useful for:

  • Creating consistent naming conventions across environments.
  • Reducing boilerplate annotations.
  • Supporting multi-tenant or dynamic environments.
  • Migrating from one DNS scheme to another
  • Supporting multiple variants, such as a regional one and then one that doesn't or similar.

How It Works

ExternalDNS has a flag: --fqdn-template, which defines a Go template for rendering the desired DNS names.

The template uses the following data from the source object (e.g., a Service or Ingress):

FieldDescriptionHow to Access
KindObject kind (e.g., Service, Pod, Ingress){{ .Kind }}
APIVersionAPI version (e.g., v1, networking.k8s.io/v1){{ .APIVersion }}
NameName of the object (e.g., service){{ .Name }}
NamespaceNamespace of the object{{ .Namespace }}
LabelsMap of labels applied to the object{{ .Labels.key }} or {{ index .Labels "key" }}
AnnotationsMap of annotations{{ index .Annotations "key" }}
SpecObject spec with type-specific fields{{ .Spec.Type }}, {{ index .Spec.Selector "app" }}
StatusObject status with type-specific fields{{ .Status.LoadBalancer.Ingress }}

To explore all available fields for an object type, use kubectl explain:

bash
# View all fields for a Service recursively.
kubectl explain service --api-version=v1 --recursive

# View all fields for a Ingress recursively.
kubectl explain ingress --api-version=networking.k8s.io/v1 --recursive

# View a specific field path. The dot notation is for field path.
kubectl explain service.spec.selector
kubectl explain pod.spec.containers

Supported Sources

<!-- TODO: generate from code -->
SourceDescriptionFQDN SupportedFQDN Combine
ambassador-hostQueries Ambassador Host resources for endpoints.NoNo
connectorQueries a custom connector source for endpoints.NoNo
contour-httpproxyQueries Contour HTTPProxy resources for endpoints.YesYes
crdQueries Custom Resource Definitions (CRDs) for endpoints.NoNo
emptyUses an empty source, typically for testing or no-op scenarios.NoNo
f5-transportserverQueries F5 TransportServer resources for endpoints.NoNo
f5-virtualserverQueries F5 VirtualServer resources for endpoints.NoNo
fakeUses a fake source for testing purposes.NoNo
gateway-grpcrouteQueries GRPCRoute resources from the Gateway API.YesNo
gateway-httprouteQueries HTTPRoute resources from the Gateway API.YesNo
gateway-tcprouteQueries TCPRoute resources from the Gateway API.YesNo
gateway-tlsrouteQueries TLSRoute resources from the Gateway API.NoNo
gateway-udprouteQueries UDPRoute resources from the Gateway API.NoNo
gloo-proxyQueries Gloo Proxy resources for endpoints.NoNo
ingressQueries Kubernetes Ingress resources for endpoints.YesYes
istio-gatewayQueries Istio Gateway resources for endpoints.YesYes
istio-virtualserviceQueries Istio VirtualService resources for endpoints.YesYes
kong-tcpingressQueries Kong TCPIngress resources for endpoints.NoNo
nodeQueries Kubernetes Node resources for endpoints.YesYes
openshift-routeQueries OpenShift Route resources for endpoints.YesYes
podQueries Kubernetes Pod resources for endpoints.YesYes
serviceQueries Kubernetes Service resources for endpoints.YesYes
skipper-routegroupQueries Skipper RouteGroup resources for endpoints.YesYes
traefik-proxyQueries Traefik IngressRoute resources for endpoints.NoNo

Custom Functions

<!-- TODO: generate from code -->
FunctionDescriptionExample
containsCheck if substr is in string{{ contains "hello" "ell" }} → true
isIPv4Validate an IPv4 address{{ isIPv4 "192.168.1.1" }} → true
isIPv6Validate an IPv6 address (including IPv4-mapped IPv6){{ isIPv6 "2001:db8::1" }} → true
{{ isIPv6 "::FFFF:192.168.1.1" }} → true
replaceReplace old with new{{ replace "l" "w" "hello" }} → hewwo
trimRemove leading and trailing spaces{{ trim " hello " }} → hello
toLowerConvert to lowercase{{ toLower "HELLO" }} → hello
trimPrefixRemove the leading prefix{{ trimPrefix "hello" "h" }} → ello
trimSuffixRemove the trailing suffix{{ trimSuffix "hello" "o" }} → hell
hasKeyCheck if a key exists in a map{{ hasKey .Labels "app" }} → true
fromJsonParse a JSON string into a value{{ index (fromJson "{\"env\":\"prod\"}") "env" }} → prod

Example Usage

These examples should provide a solid foundation for implementing FQDN templating in your ExternalDNS setup. If you have specific requirements or encounter issues, feel free to explore the issues or update this guide.

Basic Usage

yml
apiVersion: v1
kind: Service
metadata:
  name: my-service
  namespace: my-namespace
sh
external-dns \
  --provider=aws \
  --source=service \
  --fqdn-template="{{ .Name }}.example.com,{{ .Name }}.{{ .Namespace }}.example.tld"

# This will result in DNS entries like
>route53> my-service.example.com
>route53> my-service.my-namespace.example.tld

With Namespace

yml
---
apiVersion: v1
kind: Service
metadata:
  name: my-service
  namespace: default
---
apiVersion: v1
kind: Service
metadata:
  name: other-service
  namespace: kube-system
yml
args:
  --fqdn-template="{{.Name}}.{{.Namespace}}.example.com"

# This will result in DNS entries like
# route53> my-service.default.example.com
# route53> other-service.kube-system.example.com

Using Labels in Templates

You can also utilize labels in your FQDN templates to create more dynamic DNS entries. Assuming your service has:

yml
apiVersion: v1
kind: Service
metadata:
  name: my-service
  labels:
    environment: staging
yml
args:
  --fqdn-template="{{ .Labels.environment }}.{{ .Name }}.example.com"

# This will result in DNS entries like
# route53> staging.my-service.example.com

Multiple FQDN Templates

ExternalDNS allows specifying multiple FQDN templates, which can be useful when you want to create multiple DNS entries for a single service or ingress.

Be cautious, as this will create multiple DNS records per resource, potentially increasing the number of API calls to your DNS provider.

yml
args:
  --fqdn-template={{.Name}}.example.com,{{.Name}}.svc.example.com

Conditional Templating combined with Annotations processing

In scenarios where you want to conditionally generate FQDNs based on annotations, you can use Go template functions like or to provide defaults.

yml
args:
  - --combine-fqdn-annotation # this is required to combine FQDN templating and annotation processing
  - --fqdn-template={{ or .Annotations.dns "invalid" }}.example.com
  - --exclude-domains=invalid.example.com

Using Annotations for FQDN Templating

This example demonstrates how to use annotations in Kubernetes objects to dynamically generate Fully Qualified Domain Names (FQDNs) using the --fqdn-template flag in ExternalDNS.

The Service object includes an annotation dns.company.com/label with the value my-org-tld-v2. This annotation is used as part of the FQDN template to construct the DNS name.

yml
apiVersion: v1
kind: Service
metadata:
  name: nginx-v2
  namespace: my-namespace
  annotations:
    dns.company.com/label: my-org-tld-v2
spec:
  type: ClusterIP
  clusterIP: None

The --fqdn-template flag is configured to use the annotation value (dns.company.com/label) and append the namespace and a custom domain (company.local) to generate the FQDN.

yml
args:
  --source=service
  --fqdn-template='{{ index .ObjectMeta.Annotations "dns.company.com/label" }}.{{ .Namespace }}.company.local'

# For the given Service object, the resulting FQDN will be:
# route53> my-org-tld-v2.my-namespace.company.local

DNS Scheme Migration

If you're transitioning from one naming convention to another (e.g., from svc.cluster.local to svc.example.com), --fqdn-template allows you to generate the new records alongside or in place of the old ones — without requiring changes to your Kubernetes manifests.

yml
args:
- --fqdn-template='{{.Name}}.new-dns.example.com'

This helps automate DNS record migration while maintaining service continuity.

Using Kind for Conditional Templating

When processing multiple resource types, use .Kind to apply templates conditionally:

yml
args:
  --fqdn-template='{{ if eq .Kind "Service" }}{{ .Name }}.svc.example.com{{ end }}'

# Only Services will get DNS entries, Pods and other resources will be skipped

You can also handle multiple kinds in one template:

yml
args:
  --fqdn-template='{{ if eq .Kind "Service" }}{{ .Name }}.svc.example.com{{ end }}{{ if eq .Kind "Pod" }}{{ .Name }}.pod.example.com{{ end }}'

Using Spec Fields

Access type-specific spec fields for advanced filtering:

yml
# Only ExternalName services
args:
  --fqdn-template='{{ if eq .Kind "Service" }}{{ if eq .Spec.Type "ExternalName" }}{{ .Name }}.external.example.com{{ end }}{{ end }}'
yml
apiVersion: v1
kind: Service
metadata:
  name: web-frontend
spec:
  selector:
    app: nginx        # This selector will be used in the FQDN
    tier: frontend
  ports:
    - port: 80
---
apiVersion: v1
kind: Service
metadata:
  name: database
spec:
  selector:
    tier: backend     # Won't generate FQDN - no "app" key in selector
  ports:
    - port: 5432
yml
# Services with specific selector
args:
  --fqdn-template='{{ if eq .Kind "Service" }}{{ if index .Spec.Selector "app" }}{{ .Name }}.{{ index .Spec.Selector "app" }}.example.com{{ end }}{{ end }}'

# Result for web-frontend: web-frontend.nginx.example.com
# Result for database: (no FQDN generated - selector has no "app" key)

Iterating Over Labels with Range

Use range to iterate over labels and generate multiple FQDNs:

yml
args:
  --fqdn-template='{{ if eq .Kind "Service" }}{{ range $key, $value := .Labels }}{{ if contains $key "app" }}{{ $.Name }}.{{ $value }}.example.com{{ printf "," }}{{ end }}{{ end }}{{ end }}'

This generates an FQDN for each label key containing "app". Note:

  • $key and $value are the label key/value pairs
  • $.Name accesses the root object's Name (use $ inside range)
  • {{ printf "," }} separates multiple FQDNs

Working with Annotations

Access a specific annotation:

yml
args:
  --fqdn-template='{{ index .Annotations "dns.example.com/hostname" }}.example.com'

Iterate over annotations and filter by key:

yml
apiVersion: v1
kind: Service
metadata:
  name: my-service
  annotations:
    dns.example.com/primary: api.example.com
    dns.example.com/secondary: api-backup.example.com
    kubernetes.io/ingress-class: nginx  # Won't match - key doesn't contain "dns.example.com/"
yml
args:
  --fqdn-template='{{ range $key, $value := .Annotations }}{{ if contains $key "dns.example.com/" }}{{ $value }}{{ printf "," }}{{ end }}{{ end }}'

# Captures all annotations with keys containing "dns.example.com/"
# Result: api.example.com, api-backup.example.com

Filter annotations by value:

yml
apiVersion: v1
kind: Service
metadata:
  name: my-service
  annotations:
    custom/hostname: api.example.com
    custom/alias: www.example.com
    custom/internal: internal.local  # Won't match - value doesn't contain ".example.com"
yml
args:
  --fqdn-template='{{ range $key, $value := .Annotations }}{{ if contains $value ".example.com" }}{{ $value }}{{ printf "," }}{{ end }}{{ end }}'

# Captures all annotation values containing ".example.com"
# Result: api.example.com, www.example.com

Combine annotation key and value filters:

yml
apiVersion: v1
kind: Service
metadata:
  name: my-service
  annotations:
    dns/primary: api.example.com
    dns/secondary: api-backup.example.com
    other/hostname: internal.other.org  # Won't match - value doesn't contain "example.com"
    logging/level: debug                # Won't match - key doesn't contain "dns/"
yml
args:
  --fqdn-template='{{ if eq .Kind "Service" }}{{ range $k, $v := .Annotations }}{{ if and (contains $k "dns/") (contains $v "example.com") }}{{ $v }}{{ printf "," }}{{ end }}{{ end }}{{ end }}'

# Result: api.example.com, api-backup.example.com

Combining Kind and Label Filters

Filter by both Kind and label values:

yml
args:
  --fqdn-template='{{ if eq .Kind "Pod" }}{{ range $k, $v := .Labels }}{{ if and (contains $k "app") (contains $v "my-service-") }}{{ $.Name }}.{{ $v }}.example.com{{ printf "," }}{{ end }}{{ end }}{{ end }}'

# Generates FQDNs only for Pods with labels like app1=my-service-123
# Result: pod-name.my-service-123.example.com

Multi-Variant Domain Support

You can also support regional variants or multi-tenant architectures, where the same service is deployed to different regions or environments:

yaml
--fqdn-template='{{ .Name }}.{{ .Labels.env }}.{{ .Labels.region }}.example.com, {{ if eq .Labels.env "prod" }}{{ .Name }}.my-company.tld{{ end }}'

# Generates FQDNs for resources with labels env and region
# For a Service named "api" with labels env=prod, region=us-east-1:
# Result: api.prod.us-east-1.example.com, api.my-company.tld

# For a Service named "api" with labels env=staging, region=eu-west-1:
# Result: api.staging.eu-west-1.example.com

This is helpful in scenarios such as:

  • Blue/green deployments across domains
  • Staging vs. production resolution
  • Multi-cloud or multi-region failover strategies

Tips

  • If --fqdn-template is specified, ExternalDNS ignores any external-dns.alpha.kubernetes.io/hostname annotations.
  • You must still ensure the resulting FQDN is valid and unique.
  • Since Go templates can be error-prone, test your template with simple examples before deploying. Mismatched field names or nil values (e.g., missing labels) will result in errors or skipped entries.

FAQ

Can I specify multiple global FQDN templates?

Yes, you can. Pass in a comma separated list to --fqdn-template. Beware this will double (triple, etc) the amount of DNS entries based on how many services, ingresses and so on you have and will get you faster towards the API request limit of your DNS provider.

Where to find template syntax

FQDN Templating, Helm and improper templating syntax

The user encountered errors due to improper templating syntax:

yml
extraArgs:
  - --fqdn-template={{name}}.uat.example.com

The correct syntax should include a dot prefix: {{ .Name }}. Additionally, when using Helm's tpl function, it's necessary to escape the braces to prevent premature evaluation:

yml
extraArgs:
  - --fqdn-template={{ `{{ .Name }}.uat.example.com` }}

Handling Subdomain-Only Hostnames

In Issue #1872, it was observed that ExternalDNS ignores the --fqdn-template when the ingress host field is set to a subdomain (e.g., foo) without a full domain. The expectation was that the template would still apply, generating entries like foo.bar.example.com. This highlights a limitation to be aware of when designing FQDN templates.

:warning: This is currently not supported ! User would expect external-dns to generate a dns record according to the fqdnTemplate e.g. if the ingress name: foo and host: foo is created while fqdnTemplate={{.Name}}.bar.example.com then a dns record foo.bar.example.com should be created

yml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: foo
spec:
  rules:
  - host: foo
    http:
      paths:
      - backend:
          serviceName: foo
          servicePort: 80
        path: /

Combining FQDN Template with Annotations

In Issue #3318, a question was raised about the interaction between --fqdn-template and --combine-fqdn-annotation. The discussion clarified that when both flags are used, ExternalDNS combines the FQDN generated from the template with the annotation value, providing flexibility in DNS name construction.

Using Annotations for Dynamic FQDNs

In Issue #2627, a user aimed to generate DNS entries based on ingress annotations:

yml
args:
  - --fqdn-template={{.Annotations.hostname}}.example.com
  - --combine-fqdn-annotation
  - --domain-filter=example.com

By setting the hostname annotation in the ingress resource, ExternalDNS constructs the FQDN accordingly. This approach allows for dynamic DNS entries without hardcoding hostnames.

Using a Node's Addresses for FQDNs

yml
args:
  - --fqdn-template="{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.example.com"

This is a complex template that iternates through a list of a Node's Addresses and creates a FQDN with public IPv4 addresses.

Using hasKey for Safe Label and Annotation Access

Unlike index, which returns an empty string for both a missing key and a key with an empty value, hasKey explicitly checks for key existence. This matters for Kubernetes marker labels (e.g., service.kubernetes.io/headless: ""), where an empty value is meaningful.

Check for a label before using it in a template:

yml
apiVersion: v1
kind: Service
metadata:
  name: my-service
  labels:
    app: nginx
yml
args:
  - --fqdn-template={{ if hasKey .Labels "app" }}{{ .Name }}.{{ index .Labels "app" }}.example.com{{ end }}

# Result: my-service.nginx.example.com

This only generates an FQDN when the app label is present. Without hasKey, {{ index .Labels "app" }} would silently return "" for unlabelled resources, producing an invalid FQDN like my-service..example.com.

Combine with Kind for targeted rules:

yml
args:
  - --fqdn-template={{ if and (eq .Kind "Service") (hasKey .Labels "tier") }}{{ .Name }}.{{ index .Labels "tier" }}.example.com{{ end }}

Using fromJson to Parse Structured Labels

fromJson parses a JSON string stored in a label or annotation into a Go value, enabling templates to iterate over structured data.

Given a Service with a JSON array of DNS entries in a label:

yml
apiVersion: v1
kind: Service
metadata:
  name: my-service
  labels:
    records: '[{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]'

Use hasKey to guard against a missing label, then iterate with range to emit one FQDN per entry:

yml
args:
  - --fqdn-template={{ if hasKey .Labels "records" }}{{ range $entry := (index .Labels "records" | fromJson) }}{{ index $entry "dns" }},{{ end }}{{ end }}

# Result: entry1.internal.tld, entry2.example.tld