Documentation menu

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/hostsExternalDNS
Privilege per changesudo write to a system filenone
Wildcardsnot supportedyes
Entries after teardownlinger until cleanedexpire with the record
Resolves inside clustersnoyes
Publicly trusted TLSimpossibleyes — 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.

ItemTypical 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 volumeeffectively 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

SettingWhy it matters
--service-type-filter=LoadBalancerOnly 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.
domainFiltersA hard boundary — ExternalDNS refuses to touch anything outside this zone. Your most important safety net.
registry: txt + txtOwnerIdA companion TXT record marks each record as owned, so ExternalDNS never modifies records it did not create.
policy: upsert-onlyCreates 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
SymptomLikely cause
No records appearService is not type: LoadBalancer, has no hostname annotation, or the name falls outside domainFilters
NXDOMAIN but the record exists in the consoleNS delegation incomplete — re-check dig NS ${DNS_ZONE_NAME}
Resolves correctly but connections hangHost route missing. Check klimax status; klimax doctor diagnoses further
Resolves everywhere except one app or browserDNS 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 teardownExpected under policy: upsert-only
403 in the logsService 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

ApproachTrade-off
Raw MetalLB IPsZero setup, works today. No names, no TLS.
/etc/hosts entriesNo domain needed, but a sudo write per change, no wildcards, no in-cluster resolution, and stale entries linger silently
/etc/resolver + a local dnsmasqWildcards, 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.io172.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 resolverKeeps 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.