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.internal | Your domain + ExternalDNS | |
|---|---|---|
| Setup | none — on by default | a domain, a DNS provider account, one Helm install per cluster |
| Cost | free | ~12–15 USD / year for the domain |
| Resolves on your Mac and in the clusters | yes | yes |
| Resolves from other machines | no — only on this Mac | yes (the VIP is only reachable from this Mac, though) |
| Names without annotations | yes: <service>.<namespace>.<cluster> | no — one annotation per Service |
| Publicly trusted TLS | on this Mac: yes, from marina's own CA; elsewhere: no | yes — 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.
| 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, 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
| 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.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
| 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 marina status; marina 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-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:
| Approach | When |
|---|---|
sslip.io / nip.io | 172.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 resolver | Your 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.