ExternalDNS
ExternalDNS keeps records in your Frostmoln Managed DNS zones in sync with Kubernetes Ingress, Service, and Gateway API resources — from any Kubernetes cluster. When you expose a workload, its hostname's records are created, updated, and cleaned up automatically; you never edit DNS by hand.
Frostmoln ships a webhook provider that runs as a sidecar in the external-dns pod and translates ExternalDNS's plans into calls against the Frostmoln DNS API.
Prerequisites
- A Frostmoln DNS zone for the domain you want records in — see Managed DNS to create one.
- A credential for the webhook, one of:
- Workload Identity (recommended): no key to create or store. See Authenticate with Workload Identity.
- A tenant API key (
fmk_...) held by a user in that tenant. See Store an API key.
- Outbound HTTPS from your worker nodes to
registry.k8s.ioandregistry.sweden.frostmoln.cloud. The upstream ExternalDNS controller image is pulled fromregistry.k8s.ioand the Frostmoln webhook image fromregistry.sweden.frostmoln.cloudwhen the pod starts. If you filter egress by hostname, allowingregistry.k8s.ioalone is not enough: it redirects image downloads to cloud-hosted backing stores on other domains. Without this the controller container stays inImagePullBackOff, and the platform cannot warn you — reachability belongs to your VPC, not to us.
How it works
- Records — the webhook lists your tenant's zones and recordsets and returns them to ExternalDNS as endpoints (one recordset = one
(name, type)with all its values). - Apply — creates and updates go through the DNS API's idempotent, collection-level upsert; deletes resolve the recordset by its stable ID.
- Domain filter — by default the webhook fetches your tenant's zone names at startup and manages only those. Create a new zone → restart the pod so it is picked up (or set
DOMAIN_FILTERexplicitly). - Ownership — use ExternalDNS's default TXT registry (
--registry=txtwith--txt-owner-id=<cluster-id>) so a record is only ever changed by the cluster that created it. TXT recordsets round-trip through the API unmodified.
Install
On Frostmoln Managed Kubernetes, ExternalDNS with the Frostmoln webhook is a one-click cluster add-on. Select external-dns when you create the cluster, or add it to a cluster that is already running (portal → the cluster's Add-ons tab, fm kubernetes cluster add-addons, or the addons set on the API / Terraform resource), and the platform installs everything for you — the external-dns namespace, RBAC, the ExternalDNS controller, and the Frostmoln webhook sidecar, with --txt-owner-id already set to your cluster's id and FROSTMOLN_TENANT_ID set to your cluster's tenant. The webhook image is provided by the platform and pulled from registry.sweden.frostmoln.cloud (see the prerequisites). You never build or reference it yourself.
The only step left to you is the credential: a Workload Identity binding (recommended) or an API-key Secret. Until one exists, the webhook container on add-on version v0.22.0-4 exits with no credential and restarts (CrashLoopBackOff). On earlier versions it waits in CreateContainerConfigError. Create your DNS zone first (or restart the external-dns Deployment after creating it).
Add-on versions v0.22.0-1 and v0.22.0-2: move the pin to v0.22.0-3
On those two versions the webhook asks the API for the home tenant of the user who created the key, and manages that tenant's zones. If the key was created in another tenant — the cluster's, when your user belongs to more than one — the webhook exits at startup with 403 TENANT_ACCESS_DENIED and the pod sits in CrashLoopBackOff. From v0.22.0-3 the platform sets the webhook's tenant to the cluster's own. Both older versions are marked deprecated. Move an existing cluster with fm kubernetes cluster pin-addons <cluster-id> --version external-dns=v0.22.0-3, the portal's Add-ons tab, or addon_versions in Terraform — see Kubernetes → Add-on versions.
Add it at creation, or later
The add-on can be chosen when the cluster is created and added to a cluster that is already running. A later addition is not instant: the addition starts its own reconciliation and is typically installed within minutes; if that reconciliation cannot be started it falls back to the platform's next one, up to an hour. The cluster's add-on list shows your new selection as soon as it is accepted — that is the selection you asked for, not proof that the objects are on the cluster yet.
Removing it is a separate, named action. On the API and in Terraform you always send the full desired set of add-ons, not just the new key — and a set that simply omits external-dns is refused, because a request built from an out-of-date view of the cluster would otherwise delete an add-on you never meant to touch. Name what you are removing instead: the portal's Remove button, fm kubernetes cluster remove-addons, or on the API the request's remove field. In Terraform, dropping the key from addons is now applied in place rather than replacing the cluster.
Some clusters created before this capability shipped cannot receive new add-ons at all; the API tells you so when you try, and there the only route is to recreate the cluster with the add-on selected. ExternalDNS with the Frostmoln webhook is currently available on Frostmoln Managed Kubernetes only.
Managed Kubernetes: enable the addon
On Frostmoln Managed Kubernetes the easiest path is the external-dns cluster addon: select it when creating the cluster, or add it to a running cluster afterwards (portal → the cluster's Add-ons tab, fm kubernetes cluster add-addons, or the addons set on the API / Terraform resource), and the platform installs everything in this guide — the external-dns namespace, RBAC, and the Deployment, with --txt-owner-id already set to the cluster's id.
The only manual step is the credential. See Authenticate with Workload Identity (recommended) or Store an API key.
Added later, and removable
The addon can also be added to a cluster that is already running; the addition starts its own reconciliation and it is typically installed within minutes. If that reconciliation cannot be started, it falls back to the platform's own reconciliation period, up to an hour. It can be removed again the same way — from the portal's Add-ons tab, with fm kubernetes cluster remove-addons, or by dropping the key. A cluster created before each capability shipped cannot receive it, or cannot remove it, and says so when you try; recreate that cluster, or apply the manifest under Deploy yourself.
Removing it leaves your DNS records behind. ExternalDNS owns the records it created, and deleting the controller does not delete them — nothing will manage them afterwards. If you later delete a load balancer one of those records points at, that address returns to the pool and can be reassigned while your record still resolves to it. Re-selecting the addon on the same cluster re-adopts its own records and resumes managing them.
With Workload Identity, delete the add-on's binding (or run terraform destroy on it) to revoke its access. No key is left behind.
With an API key, the external-dns namespace and your frostmoln-dns Secret are also kept. That Secret holds a Frostmoln API key, and deleting the Secret does not revoke the key. Do these in order:
If anything else uses the same key, give that its own key first.
Revoke this key. Only the user who created it can: under Settings → API Keys in the portal, or with
fm account api-key listandfm account api-key revoke. Every key starts withfmk_, so match the key's first 12 characters against the listed key prefix. Read them from the Secret:bashkubectl -n external-dns get secret frostmoln-dns -o jsonpath='{.data.api-key}' | base64 -d | cut -c1-12Only then delete the Secret — the platform never shows the full key again, so read the prefix before the Secret is gone.
If ExternalDNS has not been removed yet, it logs authentication errors after the revoke until it is gone; do not put a new key into this Secret to silence them. To select the add-on again later, create a new key and Secret.
Replacing a cluster: take over its records
ExternalDNS only changes records whose TXT ownership record names its own cluster id. A new cluster has a new id, so it does not update records an earlier cluster published, even under the same names. Its logs show All records are already up to date while the name keeps resolving to the old cluster's address. This happens after you delete a cluster and create a new one, and it is also how a blue/green cluster move behaves.
To hand a name over to the new cluster, rewrite the owner in its TXT record when you cut over:
Find the new cluster's id with
fm kubernetes cluster list.List the zone's TXT records with
fm dns record list <zone-id> --type TXT -o json. ExternalDNS names each one after the record it owns, with the record type in front, soa-www.example.comowns the A recordwww.example.com. Its value containsexternal-dns/owner=<old cluster id>.Copy that value exactly and replace only the id after
external-dns/owner=:bashfm dns record update <zone-id> <txt-record-id> --value '<value with the new cluster id>'
Within a minute the new cluster's ExternalDNS treats the record as its own and points it at the new address. If the old cluster is still running, it stops managing that name from the same moment.
Rewrite every ownership record of a name before moving on to the next one. There is one per record type (A, AAAA, CNAME), and a name with an ownership record left on the old id is not managed by either cluster. A wildcard *.example.com is owned by a-*.example.com. This does not work for a zone-apex name, because its ownership record would fall outside the zone.
Do not reuse the old id in the new cluster's --txt-owner-id instead. On the managed add-on that value is set by the platform, and two running clusters sharing one owner id delete each other's records.
Authenticate with Workload Identity (recommended)
From add-on version v0.22.0-4, the webhook authenticates with Workload Identity. The platform mints it a short-lived, zone-scoped token and refreshes it about hourly. There is no API key to create, store in a Secret, rotate or revoke, and nothing lands in your Terraform state. The add-on's ServiceAccount (external-dns in the external-dns namespace) is already opted in, so you only grant it access.
You need:
- The Workload Identity add-on on the same cluster. See Workload Identity → Step 1.
- The external-dns add-on at v0.22.0-4 or later. Move an existing cluster's pin with
fm kubernetes cluster pin-addons <cluster-id> --version external-dns=v0.22.0-4, the portal's Add-ons tab, oraddon_versionsin Terraform. - A binding for
external-dns/external-dnswith an access policy that lets it list zones, and read, update and delete on the zones it manages. Creating or changing a record counts as an update of the zone that owns it, and deleting a record counts as a delete on that zone. Scope that rule to your zone's id:
resource "frostmoln_workload_identity_binding" "external_dns" {
cluster_id = frostmoln_kubernetes_cluster.prod.id
namespace = "external-dns"
service_account = "external-dns"
}
data "frostmoln_iam_policy_document" "external_dns" {
rule {
name = "list-zones"
access = "allow"
operations = ["network:dns-zones:list"]
targets = ["frn:network:*:*:dns-zones/*"]
}
rule {
name = "manage-zone"
access = "allow"
operations = ["network:dns-zones:read", "network:dns-zones:update", "network:dns-zones:delete"]
targets = ["frn:network:*:*:dns-zones/${frostmoln_dns_zone.example.id}"]
}
}
resource "frostmoln_iam_policy" "external_dns" {
name = "external-dns"
document = data.frostmoln_iam_policy_document.external_dns.json
}
resource "frostmoln_iam_policy_attachment" "external_dns" {
policy_id = frostmoln_iam_policy.external_dns.id
attachee_type = "workload_identity"
attachee_id = frostmoln_workload_identity_binding.external_dns.id
}network:dns-zones:delete also allows deleting the zone
Records have no permission of their own yet, so the permission external-dns needs to remove a record also lets that credential delete the whole zone it is scoped to. Keep the rule scoped to the zones external-dns manages. Without delete, external-dns publishes records but can never remove them: a record for a deleted Service stays in DNS, pointing at an address that may be reassigned, and the webhook logs 403 ... does not permit network:dns-zones:delete every minute.
With the fm CLI, create the binding with no scopes and attach the same policy:
fm kubernetes workload-identity binding create \
--cluster <cluster-id> --namespace external-dns --service-account external-dns
fm iam policy create --name external-dns --document @external-dns-policy.json
fm iam policy attach <policy-id> --type workload_identity --id <binding-id>Once the Workload Identity add-on and the binding are in place, there is nothing more to do. The credential is only wired into a pod when the pod is created, so a pod created before the Workload Identity add-on was running does not get it. That includes clusters where both add-ons were selected together. From workload-identity v0.3.0-1 the add-on restarts such a pod by itself within a couple of minutes, and it comes back with the credential. On an older workload-identity version the pod keeps using the API key or, without one, restarts with workload identity is not injected. Move the cluster to the newer version:
fm kubernetes cluster pin-addons <cluster-id> --version workload-identity=v0.3.0-1The webhook logs which credential it uses:
kubectl -n external-dns logs deploy/external-dns -c frostmoln-webhook | grep authenticating
# ... "authenticating with the workload-identity credential"Workload Identity wins over an API key
Each request uses the Workload Identity token whenever one is present, and falls back to the frostmoln-dns API key only when there is none. That happens, for example, when the Workload Identity add-on is installed but there is no binding for external-dns. So you can migrate without downtime: add the binding, check the log line above, and then revoke the key and delete its Secret. If a binding is deleted, its last token expires within about an hour. After that the webhook's requests fail with 401 until a binding exists again. The key is not used as a fallback while that expired token is still on disk.
Store an API key (fallback)
Use an API key where Workload Identity is not available: on a cluster without the Workload Identity add-on, on add-on versions before v0.22.0-4, or when you deploy the manifest below yourself. Put the tenant API key in a Secret the webhook can read:
kubectl create namespace external-dns # skip if the add-on already created it
kubectl create secret generic frostmoln-dns \
--namespace external-dns \
--from-literal=api-key=fmk_...Deploy
On Managed Kubernetes with the addon enabled you can skip this section — only the Secret above is needed.
The manifest below runs external-dns with the Frostmoln webhook as a sidecar, plus the ServiceAccount and RBAC external-dns needs. Replace <cluster-id> with a value unique to this cluster — it stamps the TXT ownership records so several clusters can safely share a zone. The pod declares the full restricted Pod Security contract (Managed Kubernetes enforces it cluster-wide; the explicit runAsUser matters because the upstream external-dns image runs as root).
On Frostmoln Managed Kubernetes the webhook image is preloaded on your nodes, and it lives in a Frostmoln-internal registry your cluster cannot reach — so <webhook-image> is not yours to choose. It must be a ref that is already on the nodes; any other value leaves the sidecar in ImagePullBackOff. Read it off the cluster and paste it in verbatim:
kubectl get nodes -o json \
| jq -r '.items[].status.images[]?.names[]?' \
| grep 'external-dns-frostmoln-webhook:' | sort -uMore than one ref means your node pool spans two node-image generations: use the one present on every node, or roll the pool so they match. No output at all means the image is not listed on a busy node — try one node at a time (kubectl get node <name> -o json), or ask support.
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', 'nodes']
verbs: ['get', 'watch', 'list']
- apiGroups: ['discovery.k8s.io']
resources: ['endpointslices']
verbs: ['get', 'watch', 'list']
- apiGroups: ['extensions', 'networking.k8s.io']
resources: ['ingresses']
verbs: ['get', 'watch', 'list']
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: external-dns
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: external-dns
subjects:
- kind: ServiceAccount
name: external-dns
namespace: external-dns
---
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
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: external-dns
image: registry.k8s.io/external-dns/external-dns:v0.21.0
args:
- --source=ingress
- --source=service
- --provider=webhook
- --registry=txt
- --txt-owner-id=<cluster-id>
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
- name: frostmoln-webhook
image: <webhook-image>
imagePullPolicy: IfNotPresent
env:
- name: FROSTMOLN_API_KEY
valueFrom:
secretKeyRef:
name: frostmoln-dns
key: api-key
ports:
- containerPort: 8080
name: http
livenessProbe:
httpGet:
path: /healthz
port: http
readinessProbe:
httpGet:
path: /healthz
port: http
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]Apply it:
kubectl apply -f external-dns.yamlThe webhook API listens on the pod loopback only — it is unauthenticated by contract and refuses any other bind address — and FROSTMOLN_API_URL must be https. Both are enforced at startup.
Configuration
The add-on sets sensible defaults. To tune the webhook sidecar, edit the external-dns Deployment and set these environment variables:
| Variable | Default | Description |
|---|---|---|
FROSTMOLN_WORKLOAD_CREDENTIALS_FILE | set by Workload Identity | The Workload Identity token file. Injected by the platform; do not set it yourself |
FROSTMOLN_API_KEY | — | Tenant API key (fmk_...). Not needed with Workload Identity |
FROSTMOLN_API_KEY_FILE | — | Read the key from a mounted file instead, to keep it out of the pod env |
FROSTMOLN_API_URL | https://api.frostmoln.cloud/api | Customer API base URL (must be https) |
FROSTMOLN_TENANT_ID | resolved from the key | The tenant whose zones are managed; set it to the key's tenant when that is not its user's home tenant |
DOMAIN_FILTER | your zone names | Comma-separated domain filter override |
ZONE_CACHE_TTL | 1m | How long the zone catalog is cached |
DRY_RUN | false | Log the changes ExternalDNS would make without applying them |
On the add-on from v0.22.0-3, FROSTMOLN_TENANT_ID is already set to the cluster's tenant from the platform's external-dns-cluster-identity ConfigMap — do not override it. Without it the webhook falls back to the key's home tenant.
Notes and caveats
- Public zones reject private and loopback targets. An A/AAAA record in a public zone must point at a routable address — a load balancer or a public IP, not a cluster-internal IP. For internal names, use a private (
vpcId) zone. - Delegate the zone before you expect public resolution. A zone answers on the internet only once your domain is delegated to its name servers — see Managed DNS → Delegate your domain.
- New zones need a webhook restart. The domain filter is fetched once at startup, so restart the external-dns pod after creating a zone you want ExternalDNS to manage.
- TXT ownership is supported natively — keep
--registry=txtwith a--txt-owner-idunique to each cluster so multiple clusters can safely share a zone. A replacement cluster has to take over its predecessor's records.