klimax is now marina. Same tool, new name since v0.3. Existing install? Migrate with marina migrate →
Documentation menu

Public hostnames with ExternalDNS

marina already names every LoadBalancer Service under demo.internal. This guide adds a second name under a domain you own — keycloak.lab.example.dev — for the two things the built-in zone cannot do: names that other machines resolve, and certificates every browser trusts.

Built-in zone or your own domain

Built-in demo.internalYour domain + ExternalDNS
Setupnone — on by defaulta domain, a DNS provider account, one Helm install per cluster
Costfree~12–15 USD / year for the domain
Resolves on your Mac and in the clustersyesyes
Resolves from other machinesno — only on this Macyes (the VIP is only reachable from this Mac, though)
Names without annotationsyes: <service>.<namespace>.<cluster>no — one annotation per Service
Publicly trusted TLSon this Mac: yes, from marina's own CA; elsewhere: noyes — see Wildcard TLS

They coexist: a Service can carry both names, and each ExternalDNS only writes the zone it owns.

Why this works with marina

marina 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 — in marina's own zone, or in yours.

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, the built-in zone already covers you. 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 marina 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="marina 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/.marina/external-dns-credentials.json"

$ gcloud iam service-accounts create "${SA}" \
    --display-name="ExternalDNS for local marina 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-public
$ kubectl --context "${CTX}" -n external-dns-public 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: "marina-lab"
domainFilters:
  - "lab.example.dev"
logLevel: info
$ helm --kube-context "${CTX}" upgrade -i external-dns-public external-dns/external-dns \
    -n external-dns-public -f external-dns.values.yaml
⚠

Use a namespace other than external-dns. Since v0.2.0, marina runs its own ExternalDNS there for demo.internal. Installing this release into the same namespace collides with it; the separate namespace and release name keep the two independent, and their domain filters keep them out of each other's zones.

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.kubernetes.io/hostname: keycloak.lab.example.dev
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 8080
  selector:
    app: keycloak

To keep the demo.internal name as well, list both, comma-separated — each ExternalDNS publishes the one inside its own zone: external-dns.kubernetes.io/hostname: keycloak.lab.example.dev,sso.dev.demo.internal.

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/
⚠

ExternalDNS v0.22.0 changed the annotation prefix. It reads external-dns.kubernetes.io/ only. The older external-dns.alpha.kubernetes.io/ annotations, which most existing material uses, are ignored without a warning — and under policy: sync the records they created are deleted. Upgrading an existing install? Either rename the annotations first, or pin the old prefix with --annotation-prefix=external-dns.alpha.kubernetes.io/ in extraArgs until you have.

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.kubernetes.io/hostname: keycloak.lab.example.dev
    metallb.io/loadBalancerIPs: 172.30.1.7

marina allocates 172.30.<num>.1–7 and 172.30.<num>.16–254 per cluster, where <num> is the cluster's assigned number (see marina 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.

marina configures this for you — set the zone in your marina 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-public
    kubectl --context "$CTX" -n external-dns-public create secret generic external-dns \
      --from-file=credentials.json="${CREDS_FILE}"
    helm --kube-context "$CTX" upgrade -i external-dns-public external-dns/external-dns \
      -n external-dns-public -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 marina fleet describe <name> -o json.

Verifying and troubleshooting

$ kubectl --context "${CTX}" -n external-dns-public logs deploy/external-dns-public --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 marina status; marina 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-public -n external-dns-public
$ kubectl --context "${CTX}" delete namespace external-dns-public

Leave the external-dns namespace alone: it holds marina's own ExternalDNS for demo.internal.

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

Use the built-in demo.internal zone. It is on by default and does what /etc/hosts entries or a hand-run dnsmasq behind /etc/resolver used to: every LoadBalancer Service already has a name on your Mac and in every cluster, kept in sync with the cluster, with no sudo per change. Two other options remain useful:

ApproachWhen
sslip.io / nip.io172.30.1.7.sslip.io resolves to 172.30.1.7 through public DNS, so it works even in tools that ignore /etc/resolver. You do not control the name.
Private zone + split-horizon resolverYour organisation already runs one and wants lab names in it.
❄

Buy the domain when you need publicly trusted TLS or names that other machines resolve — the two things a local zone cannot give you.