docs/sources/unstructured.md
The unstructured source creates DNS records from any Kubernetes resource using Go templates.
It works with custom resources (CRDs) without requiring typed Go clients.
Use this source when:
.spec or .statusNote: Prefer built-in sources when available (e.g.,
istio-virtualservice,gateway-httproute) as they provide optimized handling for those resource types.
The unstructured source can also be used with:
Knative Service - Serverless workloads expose auto-generated URLs in .status.url
status:
url: https://hello.default.example.com
Argo Rollouts - Canary/blue-green deployments with preview services in .status.canary.stableRS
status:
canary:
stableRS: my-app-stable-abc123
Linkerd ServiceProfile - Service mesh with destination overrides in .spec.dstOverrides
spec:
dstOverrides:
- authority: webapp.default.svc.cluster.local
Crossplane Composition outputs - Any Crossplane-managed cloud resource (ElastiCache, S3 websites, CloudFront, etc.)
status:
atProvider:
configurationEndpoint:
address: my-cache.abc123.cache.amazonaws.com
Cilium BGP PeeringPolicy - BGP-advertised IPs for LoadBalancer services
status:
conditions:
- type: Established
status: "True"
ACK FieldExport - AWS Controllers for Kubernetes can export resource status (RDS endpoints, S3 bucket URLs) to ConfigMaps via FieldExport, enabling dynamic DNS records
# FieldExport copies S3 bucket URL to ConfigMap
apiVersion: services.k8s.aws/v1alpha1
kind: FieldExport
spec:
from:
path: ".status.location"
resource:
group: s3.services.k8s.aws
kind: Bucket
name: my-bucket
to:
kind: configmap
name: bucket-dns
| Flag | Description |
|---|---|
--unstructured-resource | Resources to watch in resource.version.group format (repeatable) |
--fqdn-template | Go template for DNS names |
--target-template | Go template for DNS targets |
--fqdn-target-template | Go template returning host:target pairs |
--label-filter | Filter resources by labels |
--annotation-filter | Filter resources by annotations |
--combine-fqdn-annotation | Combine FQDN template and Annotations instead of overwriting |
Templates have access to typed-style fields and raw object data:
| Field | Description |
|---|---|
.Name | Object name |
.Namespace | Object namespace |
.Kind | Object kind |
.APIVersion | API version |
.Labels | Object labels |
.Annotations | Object annotations |
.Metadata | Raw metadata section |
.Spec | Raw spec section |
.Status | Raw status section |
.Object | Raw full object |
Use ConfigMaps as a lightweight DNS registry without needing custom CRDs. Useful for GitOps workflows where teams manage DNS entries via ConfigMaps in their namespaces.
apiVersion: v1
kind: ConfigMap
metadata:
name: api-dns
namespace: production
labels:
external-dns.alpha.kubernetes.io/dns-controller: "dns-controller"
data:
hostname: api.example.com
target: 10.0.0.100
external-dns \
--source=unstructured \
--unstructured-resource=configmaps.v1 \
--fqdn-template='{{index .Object.data "hostname"}}' \
--target-template='{{index .Object.data "target"}}' \
--label-filter='external-dns.alpha.kubernetes.io/controller=dns-controller'
# Result:
# api.example.com -> 10.0.0.100 (A)
external-dns \
--source=unstructured \
--unstructured-resource=rdsinstances.v1alpha1.rds.aws.crossplane.io \
--fqdn-template='{{.Name}}.db.example.com' \
--target-template='{{.Status.atProvider.endpoint.address}}'
external-dns \
--source=unstructured \
--unstructured-resource=virtualmachineinstances.v1.kubevirt.io \
--unstructured-resource=rdsinstances.v1alpha1.rds.aws.crossplane.io \
--fqdn-template='{{.Name}}.{{.Kind}}.example.com' \
--target-template='{{.Status.endpoint}}'
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: production-pool
namespace: metallb-system
annotations:
external-dns.alpha.kubernetes.io/hostname: "lb.example.com"
spec:
addresses:
- 192.168.10.11/32
external-dns \
--source=unstructured \
--unstructured-resource=ipaddresspools.v1beta1.metallb.io \
--fqdn-template='{{index .Annotations "external-dns.alpha.kubernetes.io/hostname"}}' \
--target-template='{{$addr := index .Spec.addresses 0}}{{if contains $addr "/32"}}{{trimSuffix $addr "/32"}}{{else}}{{$addr}}{{end}}'
# Result:
# lb.example.com -> 192.168.10.11 (A)
Tip: Use
containswithtrimSuffixto extract the IP from/32CIDR notation.
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
name: httpbin
namespace: ingress-apisix
spec:
http:
- name: httpbin
match:
hosts:
- httpbin.example.com
paths:
- /ip
backends:
- serviceName: httpbin
servicePort: 80
status:
apisix:
gateway: apisix-gateway.ingress-apisix.svc.cluster.local
external-dns \
--source=unstructured \
--unstructured-resource=apisixroutes.v2.apisix.apache.org \
--fqdn-template='{{.Name}}.route.example.com' \
--target-template='{{.Status.apisix.gateway}}'
# Result:
# httpbin.route.example.com -> apisix-gateway.ingress-apisix.svc.cluster.local (CNAME)
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-app-tls
namespace: production
annotations:
external-dns.alpha.kubernetes.io/target: "10.0.0.50"
spec:
secretName: my-app-tls-secret
dnsNames:
- my-app.example.com
- www.my-app.example.com
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
external-dns \
--source=unstructured \
--unstructured-resource=certificates.v1.cert-manager.io \
--fqdn-template='{{index .Spec.dnsNames 0}}' \
--target-template='{{index .Annotations "external-dns.alpha.kubernetes.io/target"}}'
# Result:
# my-app.example.com -> 10.0.0.50 (A)
apiVersion: management.cattle.io/v3
kind: Node
metadata:
name: my-node-1
namespace: cattle-system
labels:
cattle.io/creator: norman
node-role.kubernetes.io/controlplane: "true"
spec:
clusterName: c-abcde
hostname: my-node-1
status:
nodeName: worker-01
internalNodeStatus:
addresses:
- type: ExternalIP
address: 203.0.113.10
external-dns \
--source=unstructured \
--unstructured-resource=nodes.v3.management.cattle.io \
--fqdn-template='{{.Spec.hostname}}.nodes.example.com' \
--target-template='{{(index .Status.internalNodeStatus.addresses 0).address}}' \
--label-filter='node-role.kubernetes.io/controlplane=true'
# Result:
# my-node-1.nodes.example.com -> 203.0.113.10 (A)
Use AWS Controllers for Kubernetes (ACK) to dynamically populate ConfigMaps with resource endpoints. FieldExport copies values from ACK-managed resources (RDS, S3, ElastiCache) to ConfigMaps, which external-dns can then use for DNS records.
# 1. ACK creates an S3 bucket
apiVersion: s3.services.k8s.aws/v1alpha1
kind: Bucket
metadata:
name: app-assets
namespace: default
spec:
name: my-app-assets-bucket
---
# 2. FieldExport copies the bucket URL to a ConfigMap
apiVersion: services.k8s.aws/v1alpha1
kind: FieldExport
metadata:
name: export-bucket-url
namespace: default
spec:
from:
path: ".status.location"
resource:
group: s3.services.k8s.aws
kind: Bucket
name: app-assets
to:
kind: configmap
name: app-assets-dns
namespace: default
---
# 3. ConfigMap is populated by FieldExport
apiVersion: v1
kind: ConfigMap
metadata:
name: app-assets-dns
namespace: default
labels:
app.kubernetes.io/managed-by: ack-fieldexport
data:
default.export-bucket-url: "https://my-app-assets-bucket.s3.amazonaws.com/"
external-dns \
--source=unstructured \
--unstructured-resource=configmaps.v1 \
--fqdn-template='{{if eq .Kind "ConfigMap"}}{{.Name}}.cdn.example.com{{end}}' \
--target-template='{{if eq .Kind "ConfigMap"}}{{$url := index .Object.data "default.export-bucket-url"}}{{trimSuffix (trimPrefix $url "https://") "/"}}{{end}}' \
--label-filter='app.kubernetes.io/managed-by=ack-fieldexport'
# Result:
# app-assets-dns.cdn.example.com -> my-app-assets-bucket.s3.amazonaws.com (CNAME)
Create per-pod DNS records from EndpointSlice resources for headless services. Each pod gets its own DNS entry pointing to its IP address.
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: test-abc12
namespace: default
labels:
endpointslice.kubernetes.io/managed-by: endpointslice-controller.k8s.io
kubernetes.io/service-name: test-headless
service.kubernetes.io/headless: ""
addressType: IPv4
endpoints:
- addresses:
- 10.244.1.2
conditions:
ready: true
nodeName: worker1
targetRef:
kind: Pod
name: app-abc12
namespace: default
- addresses:
- 10.244.2.3
- 10.244.2.4
conditions:
ready: true
nodeName: worker2
targetRef:
kind: Pod
name: app-def34
namespace: default
ports:
- name: http
port: 80
protocol: TCP
external-dns \
--source=unstructured \
--unstructured-resource=endpointslices.v1.discovery.k8s.io \
--fqdn-target-template='{{if and (eq .Kind "EndpointSlice") (hasKey .Labels "service.kubernetes.io/headless")}}{{range $ep := .Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$ep.targetRef.name}}.pod.com:{{.}},{{end}}{{end}}{{end}}{{end}}' \
--fqdn-target-template='{{if and (eq .Kind "EndpointSlice") (hasKey .Labels "service.kubernetes.io/headless")}}{{$svcName := index .Labels "kubernetes.io/service-name"}}{{range $ep :=.Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$svcName}}.example.com:{{.}},{{end}}{{end}}{{end}}{{end}}'
# Result:
# app-abc12.pod.com -> 10.244.1.2 (A)
# app-def34.pod.com -> 10.244.2.3, 10.244.2.4 (A)
# test-abc12.example.com -> 10.244.1.2, 10.244.2.3, 10.244.2.4 (A)
The --fqdn-target-template flag returns host:target pairs, enabling 1:1 mapping between hostnames and targets. Useful when a Kubernetes resource contains arrays where each element should produce its own DNS record (e.g., EndpointSlice endpoints, multi-host configurations).
Grant external-dns access to your custom resources:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: external-dns
rules:
# Add for each resource type
- apiGroups: ["rds.aws.crossplane.io"]
resources: ["rdsinstances"]
verbs: ["get", "watch", "list"]
- apiGroups: ["<your-api-group>"]
resources: ["<your-resources>"]
verbs: ["get", "watch", "list"]