Hostnames with ExternalDNS
Give every LoadBalancer service a real, resolvable name —
keycloak.lab.example.dev instead of 172.30.1.7 —
with no /etc/hosts editing, no sudo, and the same
answer from your Mac, from inside the clusters, and from any script.
Why this works with klimax
klimax routes the kind bridge CIDR from macOS into the VM over pure L3 with no
SNAT, so a MetalLB VIP like 172.30.1.7 is directly reachable
from your Mac as itself. That is what makes this approach possible: the
address a service receives is a real, routable address on your machine, so you can
publish it in DNS like any other record.
That is the difference from an /etc/hosts scheme. You are not faking a
name-to-address mapping locally; you are publishing a true one. Anything that
resolves DNS — your browser, curl, a Go test, a pod in another
cluster — gets the same answer, and nothing needs elevated privileges.
/etc/hosts | ExternalDNS | |
|---|---|---|
| Privilege per change | sudo write to a system file | none |
| Wildcards | not supported | yes |
| Entries after teardown | linger until cleaned | expire with the record |
| Resolves inside clusters | no | yes |
| Publicly trusted TLS | impossible | yes — see Wildcard TLS |
What you need
Be clear about the prerequisite, because it is the real barrier: you need a domain you control, hosted in a DNS provider with an API. ExternalDNS writes records programmatically; it cannot invent a zone for you.
| Item | Typical cost |
|---|---|
A domain (e.g. .dev) | ~12–15 USD / year |
| A hosted zone (Cloud DNS, Route 53, …) | ~0.20–0.50 USD / month |
| Queries at lab volume | effectively zero |
If owning a domain is not an option, skip to alternatives. This guide uses Google Cloud DNS; ExternalDNS also supports Route 53, Cloudflare, Azure DNS and many others — only the provider block and the credential differ.
You will also need a running klimax cluster (see
Getting started) and gcloud,
kubectl and helm on your PATH.
$ export GCP_PROJECT=my-project
$ export DNS_APEX_LITERAL=example-dev # apex zone resource name
$ export DNS_ZONE_NAME=lab.example.dev # the delegated sub-zone
$ export DNS_ZONE_LITERAL=lab-example-dev # its resource name
Step 1 — Delegate a sub-zone for the lab
Do not point ExternalDNS at the zone for your real domain. Give it a delegated sub-zone whose only contents are lab records, so the blast radius of a mistake is that sub-zone and nothing else.
$ gcloud dns --project="${GCP_PROJECT}" managed-zones create "${DNS_ZONE_LITERAL}" \
--description="klimax lab records" \
--dns-name="${DNS_ZONE_NAME}." \
--visibility="public" --dnssec-state="off"
Then delegate to it from the apex zone by copying its nameservers into an
NS record:
$ NS_SERVERS=$(gcloud dns --project="${GCP_PROJECT}" record-sets list \
--zone="${DNS_ZONE_LITERAL}" --name="${DNS_ZONE_NAME}." --type="NS" \
--format=yaml | yq '.rrdatas | join(",")')
$ gcloud dns --project="${GCP_PROJECT}" record-sets create "${DNS_ZONE_NAME}." \
--zone="${DNS_APEX_LITERAL}" --type="NS" --ttl="3600" --rrdatas="${NS_SERVERS}"
Verify delegation resolves before going further — this is the most common place to get stuck:
$ dig +short NS "${DNS_ZONE_NAME}"
The zone is public, and so are your service names. A public zone
holding 172.30.x.y addresses tells anyone who queries it what your
services are called. Those addresses are private and unreachable from outside, so
this is a disclosure question rather than an access one — but if the names themselves
are sensitive, use a private zone with a split-horizon resolver instead.
Step 2 — Create a service account
ExternalDNS needs credentials scoped to DNS administration only:
$ export SA=external-dns-local
$ export CREDS_FILE="$HOME/.klimax/external-dns-credentials.json"
$ gcloud iam service-accounts create "${SA}" \
--display-name="ExternalDNS for local klimax clusters"
$ gcloud projects add-iam-policy-binding "${GCP_PROJECT}" \
--role='roles/dns.admin' \
--member="serviceAccount:${SA}@${GCP_PROJECT}.iam.gserviceaccount.com"
$ gcloud iam service-accounts keys create "${CREDS_FILE}" \
--iam-account "${SA}@${GCP_PROJECT}.iam.gserviceaccount.com"
roles/dns.admin is project-wide. If you host other zones in the same
project, prefer a custom role or a dedicated project for lab DNS.
Step 3 — Install ExternalDNS
$ CTX=dev # your cluster's kube context
$ kubectl --context "${CTX}" create namespace external-dns
$ kubectl --context "${CTX}" -n external-dns create secret generic external-dns \
--from-file=credentials.json="${CREDS_FILE}"
$ helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
$ helm repo update
Write the values file:
# external-dns.values.yaml
provider:
name: google
extraArgs:
- "--service-type-filter=LoadBalancer"
- "--google-project=my-project"
env:
- name: GOOGLE_APPLICATION_CREDENTIALS
value: /etc/secrets/service-account/credentials.json
extraVolumeMounts:
- name: google-service-account
mountPath: /etc/secrets/service-account/
extraVolumes:
- name: google-service-account
secret:
secretName: external-dns
sources:
- service
policy: upsert-only
registry: txt
txtOwnerId: "klimax-lab"
domainFilters:
- "lab.example.dev"
logLevel: info
$ helm --kube-context "${CTX}" upgrade -i external-dns external-dns/external-dns \
-n external-dns -f external-dns.values.yaml
What the important settings do
| Setting | Why it matters |
|---|---|
--service-type-filter=LoadBalancer | Only MetalLB-assigned services are published. Without it, ClusterIP and NodePort services are considered too. |
sources: [service] | Watch Services only. Add ingress to publish Ingress hostnames as well. |
domainFilters | A hard boundary — ExternalDNS refuses to touch anything outside this zone. Your most important safety net. |
registry: txt + txtOwnerId | A companion TXT record marks each record as owned, so ExternalDNS never modifies records it did not create. |
policy: upsert-only | Creates and updates records, but never deletes. See below. |
upsert-only is load-bearing when several clusters share one
zone. Under policy: sync, an instance deletes any record inside
domainFilters that it believes it owns but has no matching Service for.
Point two clusters at the same zone with the same txtOwnerId and they
will each delete the other's records in a loop. upsert-only makes that
impossible. The cost is that records outlive the services that created them — if you
want automatic cleanup, use policy: sync and give every cluster
its own txtOwnerId. Pick one of those two combinations;
sync with a shared owner ID is the one that bites.
Step 4 — Annotate a Service
ExternalDNS publishes a record when a LoadBalancer Service carries the hostname annotation:
apiVersion: v1
kind: Service
metadata:
name: keycloak
annotations:
external-dns.alpha.kubernetes.io/hostname: keycloak.lab.example.dev
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 8080
selector:
app: keycloak
Within a reconcile cycle — about a minute by default — the name resolves:
$ dig +short keycloak.lab.example.dev
172.30.1.7
$ curl -sS http://keycloak.lab.example.dev/
Keeping the address stable
MetalLB allocates from the cluster's pool in order, so a service can land on a different VIP after a teardown. Pin it if that matters:
metadata:
annotations:
external-dns.alpha.kubernetes.io/hostname: keycloak.lab.example.dev
metallb.io/loadBalancerIPs: 172.30.1.7
klimax allocates 172.30.<num>.1–7 and
172.30.<num>.16–254 per cluster, where <num> is
the cluster's assigned number (see klimax cluster list). Pinning from the
low range keeps pinned addresses clearly separated from dynamic ones.
The annotation domain is metallb.io. Older material uses
metallb.universe.tf/loadBalancerIPs, which current MetalLB no longer
honours.
Step 5 — Resolve the same names inside the clusters
Host-side resolution now works. Pods, however, resolve through CoreDNS, which will not necessarily return the answer you want for your lab zone.
klimax configures this for you — set the zone in your klimax config:
kind:
customDnsResolvers:
- domain: "lab.example.dev"
# resolvers default to 8.8.8.8 / 8.8.4.4 when omitted
Every cluster created afterwards gets a CoreDNS forward rule for that zone, so
keycloak.lab.example.dev resolves identically from a pod and from your
Mac. That matters for anything calling services by hostname — mTLS, OIDC issuer URLs,
webhook callbacks — where the name must agree on both sides.
customDnsResolvers is applied at cluster creation.
Existing clusters keep the configuration they were created with; recreate them to
pick up a change. See Configuration.
Multiple clusters, one zone
Running several clusters against a single zone is the common case:
$ for CTX in dev staging prod; do
kubectl --context "$CTX" create namespace external-dns
kubectl --context "$CTX" -n external-dns create secret generic external-dns \
--from-file=credentials.json="${CREDS_FILE}"
helm --kube-context "$CTX" upgrade -i external-dns external-dns/external-dns \
-n external-dns -f external-dns.values.yaml
done
Namespace the hostnames per cluster so they cannot collide —
keycloak.dev.lab.example.dev,
keycloak.staging.lab.example.dev — and re-read the
upsert-only warning above before changing policy or
txtOwnerId. For fleets, the same loop works over
klimax fleet describe <name> -o json.
Verifying and troubleshooting
$ kubectl --context "${CTX}" -n external-dns logs deploy/external-dns --tail=50
| Symptom | Likely cause |
|---|---|
| No records appear | Service is not type: LoadBalancer, has no hostname annotation, or the name falls outside domainFilters |
NXDOMAIN but the record exists in the console | NS delegation incomplete — re-check dig NS ${DNS_ZONE_NAME} |
| Resolves correctly but connections hang | Host route missing. Check klimax status; klimax doctor diagnoses further |
| Resolves everywhere except one app or browser | DNS rebinding protection. Many resolvers, routers and browsers reject public DNS answers containing private addresses. Use a resolver that permits it, or a private zone |
| Records survive teardown | Expected under policy: upsert-only |
403 in the logs | Service account lacks roles/dns.admin, or the key belongs to another project |
Teardown
$ helm --kube-context "${CTX}" uninstall external-dns -n external-dns
$ kubectl --context "${CTX}" delete namespace external-dns
Under upsert-only, records survive. Remove them explicitly:
$ gcloud dns --project="${GCP_PROJECT}" record-sets delete \
keycloak.lab.example.dev. --zone="${DNS_ZONE_LITERAL}" --type=A
Deleting the whole sub-zone is the quickest reset between labs, since nothing else lives there — which is exactly why Step 1 delegates one.
If you cannot own a domain
| Approach | Trade-off |
|---|---|
| Raw MetalLB IPs | Zero setup, works today. No names, no TLS. |
/etc/hosts entries | No domain needed, but a sudo write per change, no wildcards, no in-cluster resolution, and stale entries linger silently |
/etc/resolver + a local dnsmasq | Wildcards, and no sudo per change — macOS delegates a whole TLD to a nameserver you run. But it is a pointer to a resolver, not a mapping: you have to run and feed that resolver. Worth it mainly if you already have one |
sslip.io / nip.io | 172.30.1.7.sslip.io resolves to 172.30.1.7 with no setup at all. Good for demos; you do not control the name and cannot get a wildcard certificate for it |
| Private zone + split-horizon resolver | Keeps names off the public internet, at the cost of running a resolver |
If you will use this more than a handful of times, buy the domain. Everything above gets simpler, and it unlocks real wildcard TLS, which none of the alternatives can offer.