Local DNS and routing¶
This page is the LAN story: how grafana.k8s.home.example.com becomes the ingress VIP on your network, who is allowed to create that record, and how that differs from a public name that Let's Encrypt can see.
Wave 6 only installs external-dns (RFC2136 tutorial). It cannot replace a nameserver. BIND 9 on the NAS is the authoritative server; updates follow RFC 2136. If clients cannot resolve the zone before the cluster exists, they will not resolve it after either.
What “local routing” means here¶
A browser on the LAN does not talk to a Kubernetes Service name (grafana.monitoring.svc). It talks to a DNS name you chose, which must resolve to a LAN IP that MetalLB gave the ingress controller (or another LoadBalancer). In the examples that VIP is 10.0.0.30 — reserved in addressing, not a node address and not the Kubernetes API VIP (.20).
sequenceDiagram
participant Laptop
participant Resolver as LAN_resolver
participant Auth as BIND_authoritative
participant VIP as Ingress_VIP
participant Nginx as nginx_ingress
participant Pod as App_pod
Laptop->>Resolver: A grafana.k8s.home.example.com
Resolver->>Auth: recurse or forward that zone
Auth-->>Laptop: 10.0.0.30
Laptop->>VIP: HTTPS SNI grafana.k8s.home.example.com
VIP->>Nginx: same
Nginx->>Pod: HTTP to Service
TLS is a separate problem. The certificate on that handshake is issued by Step-CA for internal names, or Let's Encrypt for public names. DNS only has to land the packet on the VIP.
Name plan (do this first)¶
Pick one internal zone that will never need to resolve on the public internet:
| Piece | Example | Who owns the records |
|---|---|---|
| LAN / house zone (optional) | home.example.com |
You, statically: unraid.home.example.com, router.home.example.com |
| Cluster zone | k8s.home.example.com |
external-dns (dynamic A/TXT) |
| Public zone (optional) | k8s.example.com or example.com |
Public DNS (Cloudflare, registrar) + Let's Encrypt |
App hostnames then look like:
- Internal UI:
grafana.k8s.home.example.com - Public app:
app.k8s.example.com
Do not put Grafana on a public name unless you mean it. Internal names never get a Let's Encrypt cert via HTTP-01, because Let's Encrypt's validators are not on your LAN.
A wildcard *.k8s.home.example.com → <ingress VIP> is a valid fallback if you skip external-dns. Per-host records are better once automation works: you can see what is live, and you can point a single Service (non-HTTP) at a different VIP.
Two resolver topologies¶
BIND (or Knot, PowerDNS, Windows DNS — anything that speaks RFC2136 + TSIG) must be authoritative for k8s.home.example.com. Something else on the LAN must ask that server when a client looks the name up.
A — Authoritative-only BIND (typical with OPNsense / Unifi / a router)¶
Laptop DHCP DNS ──► router or Pi-hole (recursive)
│
│ stub / domain override:
│ k8s.home.example.com → 10.0.0.2
▼
BIND on Unraid :53
(authoritative only, recursion no)
- DHCP still hands out the router (or Pi-hole) as the only resolver. You do not change every client.
- You must add a domain override / stub / forwarder on that resolver for
k8s.home.example.com(andhome.example.comif you use it). - OPNsense: Services → Unbound → Overrides → Domain (or a forwarding zone) pointing at the BIND IP.
- Pi-hole v6 / dnsmasq:
server=/k8s.home.example.com/10.0.0.2 - Unifi: local DNS record is per-host; a full zone usually means you run BIND anyway and forward from the gateway.
B — BIND is the LAN resolver¶
- Simpler BIND config, one box to debug.
- If Unraid is down, the whole LAN loses DNS unless you add a second resolver. Fine for a lab; bad if Unraid reboots often.
- Point Talos
machine.network.nameserversat this IP (and maybe the router as a second).
This starter assumes A unless you say otherwise: Unraid already runs other infra, the router stays the DHCP DNS, and BIND only answers for the cluster zone.
BIND on Unraid (authoritative + RFC2136)¶
You can use the official ISC image, a community Unraid template, or BIND on a small VM. The config below is the part that matters.
1. TSIG key¶
On a workstation with tsig-keygen (bind package) or ldns-keygen:
You get something like:
The secret is what you later kubeseal into external-dns as Secret tsig key secret. The key name must match --rfc2136-tsig-keyname.
2. named.conf (shape)¶
options {
directory "/var/cache/bind";
listen-on { 10.0.0.2; };
listen-on-v6 { none; };
allow-query { 10.0.0.0/24; };
recursion no; // topology A
dnssec-validation no; // lab; enable later if you want
};
include "/etc/bind/externaldns-key.conf";
zone "k8s.home.example.com" {
type master;
file "/var/lib/bind/k8s.home.example.com.zone";
allow-transfer { key "externaldns-key"; };
update-policy {
grant externaldns-key zonesub ANY;
};
};
update-policy grant … zonesub ANY lets that TSIG key create/delete any record under the zone (A, TXT, and the external-dns- TXT ownership records). That is what external-dns needs.
allow-update { key "externaldns-key"; }; also works. Do not allow-update { 10.0.0.0/24; } without TSIG on a homelab you do not fully trust.
3. Zone file¶
$ORIGIN k8s.home.example.com.
$TTL 60
@ IN SOA ns.home.example.com. hostmaster.example.com. (
1 3600 600 86400 60 )
IN NS ns.home.example.com.
; optional static pin so ingress works before the first external-dns run
*.k8s.home.example.com. IN A 10.0.0.30
TTL 60 keeps a bad record from sticking. The SOA serial is ignored for dynamic updates; BIND rewrites the file.
ns.home.example.com should exist in the parent zone (static A to the BIND IP) if anything walks NS records. Many LAN resolvers never will.
4. What external-dns actually writes¶
For Ingress host grafana.k8s.home.example.com whose Service is a LoadBalancer at 10.0.0.30:
| Record | Purpose |
|---|---|
grafana.k8s.home.example.com A 10.0.0.30 |
The lookup clients use |
external-dns-grafana.k8s.home.example.com TXT "heritage=external-dns,external-dns/owner=k8s,…" |
Ownership so two clusters do not fight |
--txt-prefix=external-dns- and --txt-owner-id=homelab in this repo must stay unique if you ever run a second cluster against the same zone.
--domain-filter and --rfc2136-zone must be the same zone you created. A filter of home.example.com with a zone of k8s.home.example.com will skip names or fail updates.
5. Who BIND sees (source IP)¶
Pods do not usually source from the pod CIDR on the way to Unraid. kube-proxy / CNI masquerades to a node IP. Allowing “the cluster nodes” is enough if you also require TSIG.
If updates fail with REFUSED or NOTAUTH, check BIND logs for the source IP and whether the TSIG name matched. Clock skew of more than a few minutes breaks HMAC.
Seal the key after wave 1 (see secrets). The Deployment in values/external-dns/deployment.yaml will CrashLoop until Secret tsig exists.
Router / Unbound override (topology A)¶
Example: Unbound on OPNsense, BIND at 10.0.0.2.
- Domain override: domain
k8s.home.example.com, IP10.0.0.2. - Do the same for
home.example.comif BIND (or Unraid) is authoritative for the parent too.
Test from a laptop, not only from a node:
# Does the LAN resolver know where the zone lives?
dig grafana.k8s.home.example.com @10.0.0.1
# Does BIND itself answer?
dig grafana.k8s.home.example.com @10.0.0.2 +norecurse
# After external-dns (or the wildcard) exists:
dig +short grafana.k8s.home.example.com
# expect the ingress VIP, e.g. 10.0.0.30
If @10.0.0.2 works and @10.0.0.1 does not, the override is wrong. If both fail, the zone or record is wrong.
Talos nodes need the same path: machine.network.nameservers should be the LAN resolver (router / Pi-hole), not 8.8.8.8 only. Cluster pods that look up *.k8s.home.example.com (webhooks, OIDC, Grafana datasources) use CoreDNS, which forwards to those node nameservers.
Split horizon (internal vs public)¶
| Client | Name | Resolver | Answer | Certificate |
|---|---|---|---|---|
| Laptop on LAN | grafana.k8s.home.example.com |
Router → BIND | Ingress VIP | Step-CA |
| Laptop on LAN | app.k8s.example.com |
Public DNS (or split) | Ingress VIP or public IP | Let's Encrypt |
| Phone on LTE | grafana.k8s.home.example.com |
ISP | NXDOMAIN (good) | — |
| Internet | app.k8s.example.com |
Public DNS | WAN IP → ingress VIP | Let's Encrypt |
WAN port-forwards (or 1:1 NAT) for 80 and 443 go to the ingress VIP (10.0.0.30 in the examples), not a node and not the Kubernetes API VIP (.20). Public DNS for those names should be the WAN address (or the VIP if you publish a LAN IP, which you usually should not). Let's Encrypt HTTP-01 is the same path: the validator hits port 80 on the WAN, which must land on nginx.
If you publish app.k8s.example.com to the world, hairpin / NAT reflection on the router decides whether a LAN client hitting the public name still lands on the VIP. Many people keep UIs on *.k8s.home.example.com only so they never depend on that.
A second external-dns Deployment (Cloudflare, Route53) is how a long-lived lab updates the public zone. This starter ships one RFC2136 Deployment. Add another later; do not point one Deployment at two unrelated providers.
Wildcard vs automation¶
| Approach | When to use |
|---|---|
Static *.k8s.home.example.com A <VIP> |
Day one, or if you refuse dynamic updates |
| external-dns per Ingress | Default in this repo |
| Both | Wildcard as a safety net; external-dns overwrites specific names |
If you keep the wildcard and external-dns, a deleted Ingress still answers via the wildcard (and still hits nginx, which 404s). Some people like that; some find it confusing.
HTTP UIs that live on the NAS or the router still use this Ingress VIP. That is Ingress → Service → EndpointSlice, not a second LoadBalancer: LAN backends.
Non-HTTP (LoadBalancer, not Ingress)¶
A TCP or UDP Service that is not HTTP does not go through nginx. MetalLB assigns an IP from the apps pool. Create DNS yourself or annotate the Service:
metadata:
annotations:
external-dns.alpha.kubernetes.io/hostname: mqtt.k8s.home.example.com
spec:
type: LoadBalancer
allocateLoadBalancerNodePorts: false
That A record will not be the ingress VIP. Do not reuse the *. wildcard for those names, or clients will hit nginx on 443 instead of the real port.
Skip / substitute¶
| You want | Do this |
|---|---|
| No BIND | Delete applications/external-dns.yaml. Put a wildcard A on the router. |
| Cloudflare only | No internal zone. Every name is public; use Let's Encrypt; skip Step-CA. |
| Pi-hole local DNS records | Fine for five hosts. Painful as a zone database. Use BIND for k8s.home.example.com. |
| CoreDNS only (in-cluster) | Laptops will not use it. You still need a LAN nameserver. |
Validation
Do not start wave 6 or a first app Ingress until 1–5 pass from a laptop (not only from Unraid):
- Zone
k8s.home.example.comexists on BIND; SOA/NS load without errors (named-checkzone). - TSIG key created; secret not in Git.
- Router/Pi-hole forwards that zone to BIND (topology A) or DHCP points at BIND (topology B).
dig +short dummy.k8s.home.example.comreturns the ingress VIP (wildcard or a test record). NXDOMAIN or a public IP = stop.- Talos
nameserversare the LAN resolver (talosctl get resolvers/ the patch you applied).
After wave 6 + sealed TSIG: Ingress hosts appear as A + TXT in BIND (dig AXFR with the key, or the BIND log). Certificates for those names: Step-CA.