{"id":3527,"date":"2026-05-26T09:38:00","date_gmt":"2026-05-26T15:38:00","guid":{"rendered":"https:\/\/yyc.ninja\/index.php\/2026\/10\/20\/networking-a-homelab-kubernetes-cluster-gateway-api-metallb-dns-traps\/"},"modified":"2026-10-23T11:26:19","modified_gmt":"2026-10-23T17:26:19","slug":"networking-a-homelab-kubernetes-cluster-gateway-api-metallb-dns-traps","status":"publish","type":"post","link":"https:\/\/yyc.ninja\/index.php\/2026\/05\/26\/networking-a-homelab-kubernetes-cluster-gateway-api-metallb-dns-traps\/","title":{"rendered":"Networking a homelab Kubernetes cluster: Gateway API, MetalLB and four DNS traps"},"content":{"rendered":"<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"630\" src=\"https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover.png\" alt=\"Networking a homelab Kubernetes cluster: Gateway API, MetalLB and four DNS traps\" class=\"wp-image-3526\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" srcset=\"https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover.png 1200w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-300x158.png 300w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-1024x538.png 1024w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-768x403.png 768w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-100x53.png 100w\" \/><\/figure>\n<p class=\"wp-block-paragraph\"><em>Part 2 of the data-e2e series. Issue numbers (#95) refer to the deployment log.<\/em><\/p>\n<p class=\"wp-block-paragraph\">Getting 13 web UIs out of a Kubernetes cluster, onto proper HTTPS names that work from every device in the house,\nsounds like a solved problem. It mostly is. This post is about the parts that weren&#8217;t: the bits where a home network\nbehaves nothing like a cloud VPC.<\/p>\n<h3 class=\"wp-block-heading\">The shape of it<\/h3>\n<pre><code>browser \u2500\u25ba LAN DNS (Technitium): *.apps.example.com \u2192 &lt;lan&gt;.128\n        \u2500\u25ba Nginx Proxy Manager (.128, certificate *.apps.example.com)\n        \u2500\u25ba Traefik on MetalLB IP &lt;lan&gt;.241 (SNI = the requested host)\n        \u2500\u25ba Gateway &quot;lakehouse&quot; \u2500\u25ba HTTPRoute per app \u2500\u25ba ClusterIP Service\n<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Each layer was chosen for a reason:<\/p>\n<ul class=\"wp-block-list\">\n<li><strong>Gateway API instead of Ingress.<\/strong> ingress-nginx was retired upstream in March 2026, and RKE2 1.36 makes Traefik\n the default. Gateway API also splits responsibilities: one shared <code>Gateway<\/code> (owned by the platform) holds the\n wildcard certificate, and each namespace attaches its own <code>HTTPRoute<\/code>. A namespace opts in with a label\n (<code>gateway.example.com\/routes=true<\/code>), so a tenant can&#8217;t hijack someone else&#8217;s hostname.<\/li>\n<li><strong>MetalLB<\/strong> gives Traefik a real LAN IP (<code>.241<\/code>) in L2 mode: one node answers ARP for it. There are no cloud load\n balancers at home.<\/li>\n<li><strong>One wildcard certificate<\/strong> from Let&#8217;s Encrypt via DNS-01 (cert-manager + a Cloudflare API token), so no app needs\n its own certificate and nothing has to be reachable from the internet to get one.<\/li>\n<li><strong>Nginx Proxy Manager in front.<\/strong> The house already had two NPMs (one per domain family). Sending all LAN and,\n later, Cloudflare-tunnel traffic through the one that owns <code>apps.example.com<\/code> means there is one entry point to secure.\n A new app needs only an HTTPRoute: no DNS record, no NPM change, thanks to a wildcard at both layers (#112).<\/li>\n<\/ul>\n<p class=\"wp-block-paragraph\">Now the traps.<\/p>\n<h3 class=\"wp-block-heading\">Trap 1: the LAN answers for the whole internet (DNS-01 stuck)<\/h3>\n<p class=\"wp-block-paragraph\">The certificate wouldn&#8217;t issue: &#8220;DNS record not yet propagated&#8221;, though the TXT record was plainly visible in the\nCloudflare dashboard (#95). Querying Cloudflare&#8217;s authoritative nameservers <em>directly<\/em> from inside the LAN returned\nNXDOMAIN.<\/p>\n<p class=\"wp-block-paragraph\">The tell was in the flags. The answer had <code>rd ra<\/code> and no <code>aa<\/code>, so it was not from an authoritative server at all.\n<strong>The home network intercepts every DNS packet on port 53<\/strong>, whatever IP it&#8217;s addressed to (common with parental\ncontrols and some routers). The local resolver had a forwarder zone for <code>apps.example.com<\/code> and knew nothing about\n<code>_acme-challenge<\/code>.<\/p>\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> let cert-manager check propagation over DNS-over-HTTPS, which can&#8217;t be intercepted that way:<\/p>\n<pre><code>--dns01-recursive-nameservers-only\n--dns01-recursive-nameservers=https:\/\/cloudflare-dns.com\/dns-query,https:\/\/dns.google\/dns-query\n<\/code><\/pre>\n<p class=\"wp-block-paragraph\">The certificate issued in two minutes and renews automatically.<\/p>\n<h3 class=\"wp-block-heading\">Trap 2: the router throws away private answers (rebind protection)<\/h3>\n<p class=\"wp-block-paragraph\">Everything worked from the workstation and nothing worked from the phones (#110).<\/p>\n<ul class=\"wp-block-list\">\n<li>The workstation asks Technitium directly.<\/li>\n<li>Phones ask the router, which asks Technitium.<\/li>\n<li>The router runs dnsmasq with <code>rebind_protection<\/code>: if a public domain answers with a private IP (<code>10.x<\/code>,\n <code>192.168.x<\/code>), dnsmasq assumes a DNS-rebinding attack and replaces the answer with <em>nothing<\/em>. The client sees NOERROR\n with zero records and gives up.<\/li>\n<\/ul>\n<p class=\"wp-block-paragraph\">The fix is one line per internal domain (<code>rebind_domain='&lt;your-domain&gt;'<\/code>), plus the same line in the router&#8217;s\nrestore-after-flash script. Without the second part, the next firmware upgrade silently breaks it again, which is\nexactly what happened here once already.<\/p>\n<p class=\"wp-block-paragraph\">This one is worth remembering when you add a <strong>Cloudflare tunnel<\/strong>. From the internet everything works, because\npublic DNS points at Cloudflare. From home, the same names fail until the rebind whitelist is in place. &#8220;Works\noutside, broken inside&#8221; is the signature.<\/p>\n<h3 class=\"wp-block-heading\">Trap 3: a forwarder pointing at itself<\/h3>\n<p class=\"wp-block-paragraph\">Technitium&#8217;s <code>apps.example.com<\/code> zone was a <em>forwarder<\/em> zone whose forwarder was <code>127.0.0.1<\/code>, itself (#111). Any name without\na local record (the apex, <code>www<\/code>) returned SERVFAIL locally. The fix: real upstream forwarders plus one wildcard record\n<code>*.apps.example.com \u2192 NPM<\/code>.<\/p>\n<p class=\"wp-block-paragraph\">An attempted apex record pointing at NPM was rolled back. NPM had no proxy host for the apex, so LAN clients lost the\npublic website. Wildcards don&#8217;t cover the apex, and that&#8217;s usually what you want.<\/p>\n<h3 class=\"wp-block-heading\">Trap 4: IPv6 that isn&#8217;t there<\/h3>\n<p class=\"wp-block-paragraph\">Argo CD couldn&#8217;t fetch a 180 KB Git repository: <code>git fetch \u2026 timeout after 1m40s<\/code> (#100). The pods have IPv4 only,\nbut <code>gitlab.com<\/code> resolves to an IPv6 address first. OpenSSH tried it, waited on a connect that could never succeed,\nand Argo&#8217;s 90-second exec timeout won the race.<\/p>\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> a mounted SSH client config with <code>AddressFamily inet<\/code>. It&#8217;s a good default for any homelab pod network that\nlacks IPv6.<\/p>\n<h3 class=\"wp-block-heading\">The bug that looked like a broken login form<\/h3>\n<p class=\"wp-block-paragraph\">About half of all JupyterHub logins through the Gateway failed with &#8220;Login form invalid or expired&#8221; (#108). The other\nhalf worked. Same browser, same password.<\/p>\n<ul class=\"wp-block-list\">\n<li>JupyterHub&#8217;s anonymous login token (XSRF) is bound to the <strong>client IP<\/strong> and user agent.<\/li>\n<li>Traefik&#8217;s Service used <code>externalTrafficPolicy: Cluster<\/code>. MetalLB delivers traffic to one node, and kube-proxy may\n forward it to a Traefik pod on <em>another<\/em> node, rewriting the source IP (SNAT) to that node&#8217;s address on the way.<\/li>\n<li>So the GET that rendered the form and the POST that submitted it reached JupyterHub from two different &#8220;client\n IPs&#8221;.<\/li>\n<\/ul>\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> <code>externalTrafficPolicy: Local<\/code>. Traffic now enters only on the node running Traefik, the real client IP is\npreserved, and logins went 10\/10. The verification script now logs in three times in a row. A single success proves\nnothing for an intermittent bug.<\/p>\n<h3 class=\"wp-block-heading\">Apps that don&#8217;t trust proxies<\/h3>\n<p class=\"wp-block-paragraph\">Putting apps behind a reverse proxy surfaces each app&#8217;s opinion about <code>X-Forwarded-*<\/code> and <code>Host<\/code> (#96, #99):<\/p>\n<ul class=\"wp-block-list\">\n<li><strong>Trino<\/strong> returned <code>406<\/code> until <code>http-server.process-forwarded=true<\/code>.<\/li>\n<li><strong>MLflow 3<\/strong> has a host-header allow-list (<code>serverAllowedHosts<\/code>) and returned <code>403<\/code> for the new name.<\/li>\n<li><strong>NiFi 2<\/strong> enforces SNI against its own certificate. That certificate is generated at first start from the proxy\n hosts it knows <em>then<\/em>. Adding a name later means regenerating the keystore. The documented &#8220;disable SNI check&#8221;\n switch has no effect in 2.12.<\/li>\n<\/ul>\n<p class=\"wp-block-paragraph\">None of these are bugs. They are security features doing their job, and they all fail closed. Expect one per app.<\/p>\n<h3 class=\"wp-block-heading\">NetworkPolicies cut both ways<\/h3>\n<p class=\"wp-block-paragraph\">JupyterHub&#8217;s chart ships a NetworkPolicy that blocks notebook egress to <strong>private IPs<\/strong>. In a cluster, that&#8217;s every\nService (#71). From a notebook, MLflow, Trino and S3 all timed out.<\/p>\n<p class=\"wp-block-paragraph\">The tempting fix (<code>privateIPs: true<\/code>) would also have opened the entire home LAN to every notebook. Instead the policy\nallows egress to the data-platform namespaces only, so each new namespace has to be added deliberately. Default-deny\nwith explicit allows is the model worth following everywhere.<\/p>\n<h3 class=\"wp-block-heading\">Checklist<\/h3>\n<ol class=\"wp-block-list\">\n<li><strong>Choose the LB range and remove it from DHCP<\/strong>: pool <em>and<\/em> reservations.<\/li>\n<li><strong>Whitelist every split-horizon domain<\/strong> in the router&#8217;s rebind protection, and in its restore script.<\/li>\n<li><strong>Use DoH (or a resolver you control) for ACME DNS-01 checks<\/strong> if anything on the LAN intercepts port 53.<\/li>\n<li><strong><code>externalTrafficPolicy: Local<\/code><\/strong> on the ingress Service if any app ties sessions to client IPs, or if you want\n real IPs in your logs.<\/li>\n<li><strong>Force IPv4 for SSH and Git<\/strong> in pods if the cluster has no IPv6.<\/li>\n<li><strong>Per app:<\/strong> forwarded headers, allowed hosts, SNI.<\/li>\n<li><strong>Wildcard DNS + wildcard certificate + one Gateway<\/strong>: adding an app should be one HTTPRoute and nothing else.<\/li>\n<\/ol>\n<p class=\"wp-block-paragraph\"><em>Previous: Part 1 (one spinning disk \u00b7 Next: Part 3) Adopting a live cluster into\nGitOps.<\/em><\/p>\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n<!-- \/wp:post-content -->\n\n<!-- wp:heading -->\n<h2 class=\"wp-block-heading\">Get the next one in your inbox<\/h2>\n<!-- \/wp:heading -->\n\n<!-- wp:paragraph -->\n<p class=\"wp-block-paragraph\">Enjoyed this? I write about local AI, self-hosting, and cloud cost ninjutsu. Join the Ninja Dispatch for occasional, high-signal notes. No fluff.<\/p>\n<!-- \/wp:paragraph -->\n\n<!-- wp:shortcode -->\n\n\t\t\t<form id=\"sib_signup_form_2\" method=\"post\" class=\"sib_signup_form\">\n\t\t\t\t<div class=\"sib_loader\" style=\"display:none;\"><img\n\t\t\t\t\t\t\tsrc=\"https:\/\/yyc.ninja\/wp-includes\/images\/spinner.gif\" alt=\"loader\"><\/div>\n\t\t\t\t<input type=\"hidden\" name=\"sib_form_action\" value=\"subscribe_form_submit\">\n\t\t\t\t<input type=\"hidden\" name=\"sib_form_id\" value=\"2\">\n                <input type=\"hidden\" name=\"sib_form_alert_notice\" value=\"Please fill out all required fields.\">\n                <input type=\"hidden\" name=\"sib_form_invalid_email_notice\" value=\"Please enter a valid email address.\">\n                <input type=\"hidden\" name=\"sib_security\" value=\"fb5b5107aa\">\n\t\t\t\t<div class=\"sib_signup_box_inside_2\">\n\t\t\t\t\t<div style=\"\/*display:none*\/\" class=\"sib_msg_disp\">\n\t\t\t\t\t<\/div>\n                    \t\t\t\t\t<p class=\"sib-email-area\">\n    <input type=\"email\" class=\"sib-email-area\" name=\"email\" placeholder=\"you@example.com\" required=\"required\">\n<\/p>\n<p class=\"sib-FIRSTNAME-area\">\n    <input type=\"text\" class=\"sib-FIRSTNAME-area\" name=\"FIRSTNAME\" placeholder=\"First name (optional)\">\n<\/p>\n<input type=\"hidden\" class=\"sib-SOURCE_DOMAIN-area\" name=\"SOURCE_DOMAIN\" value=\"yyc.ninja\">\n<input type=\"hidden\" class=\"sib-SIGNUP_SOURCE-area\" name=\"SIGNUP_SOURCE\" value=\"post\">\n<p>\n    <input type=\"submit\" class=\"sib-default-btn\" value=\"Join the Ninja Dispatch\">\n<\/p>\n<p class=\"ninja-fineprint\">No spam. Unsubscribe anytime.<\/p>\t\t\t\t<\/div>\n\t\t\t<\/form>\n\t\t\t<style>\n\t\t\t\tform#sib_signup_form_2{background:#192a3d;border-radius:10px;padding:16px;font-family:inherit}form#sib_signup_form_2 input[type=text],form#sib_signup_form_2 input[type=email],form#sib_signup_form_2 select{width:100%;box-sizing:border-box;margin:0 0 10px 0;padding:10px 12px;border:1px solid #33465c;border-radius:8px;background:#0f1c29;color:#e8eef5;font-size:14px}form#sib_signup_form_2 input::placeholder{color:#8a9bb0}form#sib_signup_form_2 .sib-default-btn{width:100%;margin:2px 0 0 0;padding:11px 14px;background:#e63946;color:#ffffff;border:0;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer}form#sib_signup_form_2 .sib-default-btn:hover{background:#d02f3c}form#sib_signup_form_2 p{margin:0 0 10px 0}form#sib_signup_form_2 .ninja-fineprint{margin:8px 0 0;font-size:11px;color:#8a9bb0;text-align:center}form#sib_signup_form_2 p.sib-alert-message {\n    padding: 6px 12px;\n    margin-bottom: 20px;\n    border: 1px solid transparent;\n    border-radius: 4px;\n    -webkit-box-sizing: border-box;\n    -moz-box-sizing: border-box;\n    box-sizing: border-box;\n}\nform#sib_signup_form_2 p.sib-alert-message-error {\n    background-color: #f2dede;\n    border-color: #ebccd1;\n    color: #a94442;\n}\nform#sib_signup_form_2 p.sib-alert-message-success {\n    background-color: #dff0d8;\n    border-color: #d6e9c6;\n    color: #3c763d;\n}\nform#sib_signup_form_2 p.sib-alert-message-warning {\n    background-color: #fcf8e3;\n    border-color: #faebcc;\n    color: #8a6d3b;\n}\n\t\t\t<\/style>\n\t\t\t\n<!-- \/wp:shortcode -->","protected":false},"excerpt":{"rendered":"<p>Getting 13 web UIs out of a Kubernetes cluster, onto proper HTTPS names that work from every device in the house,<\/p>\n","protected":false},"author":1,"featured_media":3526,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[12],"tags":[44,46,45],"class_list":["post-3527","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-how-to","tag-data-engineering","tag-mlops","tag-streaming"],"featured_image_urls_v2":{"full":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover.png",1200,630,false],"thumbnail":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-150x150.png",150,150,true],"medium":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-300x158.png",300,158,true],"medium_large":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-768x403.png",768,403,true],"large":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-1024x538.png",1024,538,true],"RoboGalleryMansoryImagesCenter":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-600x630.png",600,630,true],"RoboGalleryPreload":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover-100x53.png",100,53,true],"1536x1536":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover.png",1200,630,false],"2048x2048":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-networking-a-homelab-kubernetes-cluster-gateway-api-metallb-cover.png",1200,630,false]},"post_excerpt_stackable_v2":"<p>Getting 13 web UIs out of a Kubernetes cluster, onto proper HTTPS names that work from every device in the house,<\/p>\n","category_list_v2":"<a href=\"https:\/\/yyc.ninja\/index.php\/category\/how-to\/\" rel=\"category tag\">How To Ninja<\/a>","author_info_v2":{"name":"YYC Ninja","url":"https:\/\/yyc.ninja\/index.php\/author\/admin\/"},"comments_num_v2":"0 comments","_links":{"self":[{"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts\/3527","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/comments?post=3527"}],"version-history":[{"count":2,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts\/3527\/revisions"}],"predecessor-version":[{"id":3565,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts\/3527\/revisions\/3565"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/media\/3526"}],"wp:attachment":[{"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/media?parent=3527"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/categories?post=3527"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/tags?post=3527"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}