<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Networking on hippotion</title><link>https://blog.hippotion.com/tags/networking/</link><description>Recent content in Networking on hippotion</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Fri, 23 May 2025 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.hippotion.com/tags/networking/index.xml" rel="self" type="application/rss+xml"/><item><title>🛡️ How Do You Prevent a Compromised Pod From Calling Your Database?</title><link>https://blog.hippotion.com/posts/k8s-network-isolation/</link><pubDate>Fri, 23 May 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/k8s-network-isolation/</guid><description>Default Kubernetes is a flat network. Every pod can reach every other pod. In a cluster with ten services, that&amp;rsquo;s ten potential blast radiuses instead of one.</description><content:encoded><![CDATA[<h2 id="the-question">The question</h2>
<p><em>&ldquo;How do you enforce network isolation between services in a Kubernetes cluster?&rdquo;</em></p>
<p>The default Kubernetes network model is flat. Every pod can reach every other pod, in any namespace, on any port. There are no firewalls, no ACLs, no segmentation. A compromised frontend pod can connect directly to your PostgreSQL port, your Redis port, your internal admin API, and every other service in the cluster.</p>
<p>This is intentional — Kubernetes doesn&rsquo;t assume you want isolation, because not everyone does. But if you do want it, you need to add it.</p>
<hr>
<h2 id="networkpolicy-the-primitive">NetworkPolicy: the primitive</h2>
<p>A <code>NetworkPolicy</code> is a Kubernetes resource that selects a set of pods and defines what traffic is allowed to reach them (ingress) and what traffic they&rsquo;re allowed to send (egress). Traffic that isn&rsquo;t explicitly allowed is dropped.</p>
<p>The catch: <code>NetworkPolicy</code> resources have no effect unless your CNI plugin supports them. The default k3s CNI (Flannel) does not. Calico, Cilium, and Canal do. If you&rsquo;re running Flannel and you apply a NetworkPolicy, it will be silently ignored — no error, no warning.</p>
<hr>
<h2 id="the-default-deny-pattern">The default-deny pattern</h2>
<p>The correct starting point is a default-deny policy that blocks everything, applied to the namespace. You then add explicit allow policies for the traffic you actually need.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Block all ingress and egress in this namespace by default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">networking.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">default-deny-all</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">podSelector</span><span class="p">:</span><span class="w"> </span>{}<span class="w">        </span><span class="c"># matches all pods in the namespace</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">policyTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Ingress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Egress</span><span class="w">
</span></span></span></code></pre></div><p>With this in place, your pods can&rsquo;t receive traffic and can&rsquo;t send traffic. You then add back what you need.</p>
<hr>
<h2 id="allowing-specific-traffic">Allowing specific traffic</h2>
<p>Allow the web frontend to receive traffic from the ingress controller:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">networking.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">allow-ingress-from-traefik</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">podSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">policyTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Ingress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">from</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">namespaceSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kubernetes.io/metadata.name</span><span class="p">:</span><span class="w"> </span><span class="l">sys-traefik</span><span class="w">
</span></span></span></code></pre></div><p>Allow the backend to talk to PostgreSQL:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">networking.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">allow-egress-to-postgres</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">podSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">backend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">policyTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Egress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">egress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">podSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">postgres</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">5432</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span></code></pre></div><p>After these two policies: the frontend receives traffic from Traefik, and the backend can reach Postgres. The frontend cannot reach Postgres. The backend cannot receive traffic from the ingress controller. Neither can call anything else.</p>
<hr>
<h2 id="the-dns-gotcha">The DNS gotcha</h2>
<p>Once you add a default-deny egress policy, DNS stops working. Your pods can no longer resolve service names because they can&rsquo;t reach <code>kube-dns</code> in the <code>kube-system</code> namespace.</p>
<p>You need to explicitly allow it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">networking.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">allow-egress-dns</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">podSelector</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">policyTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Egress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">egress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">namespaceSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kubernetes.io/metadata.name</span><span class="p">:</span><span class="w"> </span><span class="l">kube-system</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">53</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">UDP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">53</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span></code></pre></div><p>Missing this is the most common reason &ldquo;everything broke after I added NetworkPolicies&rdquo;. Add it to every namespace that has a default-deny policy.</p>
<hr>
<h2 id="cilium-the-same-model-with-more-power">Cilium: the same model with more power</h2>
<p>Cilium implements the standard <code>NetworkPolicy</code> API and adds its own <code>CiliumNetworkPolicy</code> CRD with L7 capabilities.</p>
<p>Standard NetworkPolicy works at L3/L4 — IP addresses and ports. Cilium&rsquo;s CRD adds:</p>
<p><strong>L7 HTTP filtering</strong>: allow specific HTTP methods and paths, not just port 8080.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">cilium.io/v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">CiliumNetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">allow-api-reads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">endpointSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">api</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">fromEndpoints</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">toPorts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;8080&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">http</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- <span class="nt">method</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;GET&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;/api/v1/.*&#34;</span><span class="w">
</span></span></span></code></pre></div><p><strong>DNS-based egress</strong>: allow egress to <code>github.com</code> by hostname rather than IP address. This matters for external services with dynamic IPs.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">egress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">toFQDNs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">matchName</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;github.com&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">toPorts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;443&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span></code></pre></div><p><strong>Identity-based policies</strong>: Cilium assigns a cryptographic identity to each pod based on its labels. Policies are enforced by identity, not IP address. Pod restarts (which change IPs) don&rsquo;t break policy enforcement.</p>
<hr>
<h2 id="what-a-real-namespace-policy-set-looks-like">What a real namespace policy set looks like</h2>
<p>For a typical web app with frontend, backend, and database:</p>
<pre tabindex="0"><code>Namespace: myapp
├── default-deny-all (ingress + egress, all pods)
├── allow-egress-dns (egress, all pods, port 53)
├── allow-ingress-frontend (ingress frontend, from sys-traefik namespace)
├── allow-egress-frontend-to-backend (egress frontend, to backend:8080)
├── allow-ingress-backend (ingress backend, from frontend)
├── allow-egress-backend-to-postgres (egress backend, to postgres:5432)
└── allow-ingress-postgres (ingress postgres, from backend)
</code></pre><p>Eight policies. The database has exactly one inbound path: from the backend. The frontend has no path to the database at all. A compromised frontend pod cannot scan the internal network — egress to arbitrary destinations is blocked.</p>
<hr>
<h2 id="what-interviewers-are-actually-testing">What interviewers are actually testing</h2>
<p>The follow-up is usually: <em>&ldquo;How do you manage this at scale? Writing NetworkPolicies for every namespace by hand doesn&rsquo;t scale.&rdquo;</em></p>
<p>The answer: you don&rsquo;t write them by hand. You template them. In a GitOps setup, your namespace configuration declares what network access the service needs in a structured form, and a Helm chart or operator generates the actual NetworkPolicy resources from those declarations.</p>
<p>For example, an <code>applications.yml</code> entry might look like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">networkPolicies</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">denyAll</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">allowIngressFromIngress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">allowEgressToNamespaces</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;sys-postgres&#34;</span><span class="p">]</span><span class="w">
</span></span></span></code></pre></div><p>And a Helm chart translates that into four concrete NetworkPolicy objects. The developer declares intent; the platform enforces it. No one writes raw YAML for each namespace.</p>
<p>The second follow-up: <em>&ldquo;What about east-west traffic between services in the same namespace?&rdquo;</em> Add <code>allowIntraNamespace: true</code> as a flag that generates a policy allowing all pod-to-pod traffic within the namespace, while still blocking cross-namespace traffic.</p>
<hr>
<p><em>This is part of a series on Kubernetes interview questions. Previously: <a href="/posts/k8s-zero-downtime/">zero-downtime deployments</a>. Next: <a href="/posts/k8s-config-drift/">preventing configuration drift</a>.</em></p>
]]></content:encoded></item><item><title>🔐 Same Hostname, Two Traffic Paths: Local HTTPS Without a VPN</title><link>https://blog.hippotion.com/posts/homelab-dual-path-tls/</link><pubDate>Fri, 11 Apr 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/homelab-dual-path-tls/</guid><description>No open ports. Real TLS at home. One IngressRoute per app. This is the networking setup I landed on after ruling out everything that required a compromise.</description><content:encoded><![CDATA[<h2 id="the-three-options-that-didnt-work">The three options that didn&rsquo;t work</h2>
<p>When I started the homelab I looked at the standard ways to make self-hosted services accessible and found the usual compromises:</p>
<p><strong>Open ports on the router.</strong> Point a DNS record at your public IP, forward 443 to your server. Simple. Also: your home IP is now publicly associated with your services, your ISP can see your traffic, and a misconfigured app means an open door. Hard pass.</p>
<p><strong>VPN for everything.</strong> WireGuard on the router, every device gets a tunnel. Secure, but every phone and laptop needs to be configured, split tunnelling adds complexity, and &ldquo;let me pull up the recipe&rdquo; becomes a two-tap operation that my family won&rsquo;t do. And I still want public access for some services.</p>
<p><strong>Cloudflare Tunnel, but broken HTTPS locally.</strong> This is the one I started with. The cloudflared pod dials out to Cloudflare, no open ports, external access works great. But locally, <code>*.hippotion.com</code> resolves to Cloudflare&rsquo;s anycast IP, traffic leaves the house, Cloudflare terminates TLS, traffic comes back in through the tunnel. Every local request makes a round trip to a Cloudflare edge node. Worse: browsers cache HSTS for <code>hippotion.com</code>, so <code>http://</code> URLs on the local network silently upgrade to <code>https://</code>, which fails because there&rsquo;s no local certificate. Intermittent, confusing, and hard to explain to anyone else on the network.</p>
<p>What I wanted: the tunnel for external access, direct-to-server for local access, real TLS in both cases, and one configuration per application.</p>
<hr>
<h2 id="the-insight-pi-hole-already-controls-local-dns">The insight: Pi-hole already controls local DNS</h2>
<p>My network already runs Pi-hole for ad blocking. Pi-hole uses dnsmasq under the hood and can resolve any hostname to any IP you want. One config line in Pi-hole&rsquo;s values:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">dnsmasq</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">customDnsEntries</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">address=/hippotion.com/192.168.0.109</span><span class="w">
</span></span></span></code></pre></div><p>The <code>address=</code> directive is a wildcard. Every device on the LAN that uses Pi-hole for DNS — which is all of them, because the router hands out Pi-hole&rsquo;s IP via DHCP — will now resolve <code>anything.hippotion.com</code> to <code>192.168.0.109</code>, the server&rsquo;s LAN address. External traffic still goes to Cloudflare&rsquo;s IP because it uses the public authoritative DNS. The split is automatic; no per-device configuration.</p>
<p>Local browser → Pi-hole → server&rsquo;s LAN IP directly.</p>
<p>That solves routing. Now TLS.</p>
<hr>
<h2 id="why-http-01-wont-work-here-and-why-dns-01-will">Why HTTP-01 won&rsquo;t work here, and why DNS-01 will</h2>
<p>The standard way to get a Let&rsquo;s Encrypt certificate is the HTTP-01 challenge: Let&rsquo;s Encrypt sends a request to <code>http://yourdomain.com/.well-known/acme-challenge/&lt;token&gt;</code> and your server responds. This requires Let&rsquo;s Encrypt&rsquo;s servers to reach your server over the public internet.</p>
<p>That doesn&rsquo;t work here. There are no open ports. Let&rsquo;s Encrypt can&rsquo;t reach the server. HTTP-01 is out.</p>
<p>DNS-01 is different. Instead of proving you control a server, you prove you control the DNS zone by creating a temporary TXT record at <code>_acme-challenge.yourdomain.com</code>. Let&rsquo;s Encrypt checks DNS, finds the record, issues the cert. No inbound connection required — just API access to your DNS provider.</p>
<p><code>hippotion.com</code> is on Cloudflare. cert-manager has a Cloudflare DNS solver that calls the Cloudflare API to create and delete the TXT record automatically. The certificate request flow:</p>
<ol>
<li>cert-manager creates an ACME Order with Let&rsquo;s Encrypt</li>
<li>cert-manager calls the Cloudflare API: add <code>_acme-challenge.hippotion.com TXT &lt;token&gt;</code></li>
<li>Let&rsquo;s Encrypt queries DNS, finds the record, issues the cert</li>
<li>cert-manager deletes the TXT record, writes the cert to a Kubernetes Secret</li>
<li>cert-manager renews automatically ~30 days before expiry</li>
</ol>
<p>The Cloudflare API token needs <code>Zone:DNS:Edit</code> permission for <code>hippotion.com</code>. It lives in Vault and syncs to the <code>sys-cert-manager</code> namespace via External Secrets Operator — same pattern as every other secret in the cluster, nothing in Git.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">cert-manager.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Certificate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">hippotion-wildcard</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">sys-traefik</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">secretName</span><span class="p">:</span><span class="w"> </span><span class="l">hippotion-wildcard-tls</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">issuerRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">letsencrypt-cloudflare</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterIssuer</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">dnsNames</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;hippotion.com&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;*.hippotion.com&#34;</span><span class="w">
</span></span></span></code></pre></div><p>One certificate. Every subdomain. cert-manager stores it as <code>hippotion-wildcard-tls</code> in the <code>sys-traefik</code> namespace, where Traefik can read it.</p>
<hr>
<h2 id="two-traefik-entrypoints">Two Traefik entrypoints</h2>
<p>Traefik has three entrypoints configured:</p>
<table>
	<thead>
			<tr>
					<th>Entrypoint</th>
					<th>Port</th>
					<th>Purpose</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>web</code></td>
					<td>80</td>
					<td>Redirects all traffic to <code>websecure</code></td>
			</tr>
			<tr>
					<td><code>websecure</code></td>
					<td>443</td>
					<td>Local HTTPS, serves the wildcard cert</td>
			</tr>
			<tr>
					<td><code>cloudflare</code></td>
					<td>7080</td>
					<td>Receives plain HTTP from the cloudflared pod</td>
			</tr>
	</tbody>
</table>
<p>The <code>cloudflare</code> entrypoint is the key piece. Cloudflare Tunnel terminates TLS at Cloudflare&rsquo;s edge and forwards plain HTTP to the cluster. If that plain HTTP landed on <code>web</code> (port 80), it would get redirected to <code>websecure</code> (port 443), which would fail because cloudflared isn&rsquo;t sending HTTPS. A separate entrypoint on a separate port handles tunnel traffic without redirection.</p>
<p>Traefik is configured to use the wildcard cert as its default:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">tlsStore</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">default</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">defaultCertificate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">secretName</span><span class="p">:</span><span class="w"> </span><span class="l">hippotion-wildcard-tls</span><span class="w">
</span></span></span></code></pre></div><p>Any <code>websecure</code> request that doesn&rsquo;t match a more specific TLS configuration gets the wildcard cert. No per-app certificate configuration.</p>
<hr>
<h2 id="one-ingressroute-handles-both-paths">One IngressRoute handles both paths</h2>
<p>Every application gets a single IngressRoute with both entrypoints:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">traefik.io/v1alpha1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">IngressRoute</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">entryPoints</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">cloudflare  </span><span class="w"> </span><span class="c"># plain HTTP from cloudflared</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">websecure   </span><span class="w"> </span><span class="c"># local HTTPS with wildcard cert</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">routes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">match</span><span class="p">:</span><span class="w"> </span><span class="l">Host(`myapp.hippotion.com`)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Rule</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">middlewares</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">oauth-auth</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">sys-oauth2-gitlab</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">8080</span><span class="w">
</span></span></span></code></pre></div><p>That&rsquo;s it. The same hostname, the same routing rule, the same middleware — served correctly on both paths. No conditional logic, no separate ingress for local vs external.</p>
<p>The OAuth middleware (<code>oauth-auth</code>) works on both paths too. Local browsers get redirected to GitLab for authentication the same way external browsers do. The SSO cookie is set on <code>hippotion.com</code>, so it works across all subdomains regardless of which path the traffic came through.</p>
<hr>
<h2 id="what-the-two-traffic-paths-look-like-end-to-end">What the two traffic paths look like end to end</h2>
<pre tabindex="0"><code>External browser (anywhere):
  Browser
    → Cloudflare DNS (hippotion.com → Cloudflare anycast IP)
    → Cloudflare edge (TLS terminated, certificate managed by Cloudflare)
    → cloudflared pod in cluster (plain HTTP)
    → Traefik :7080 (cloudflare entrypoint)
    → app pod

Local browser (home WiFi):
  Browser
    → Pi-hole DNS (*.hippotion.com → 192.168.0.109)
    → Traefik :443 (websecure entrypoint)
    → Traefik serves hippotion-wildcard-tls (Let&#39;s Encrypt cert, trusted by browser)
    → app pod
</code></pre><p>Both paths hit the same Traefik IngressRoute rule. The app sees an HTTP request either way. TLS is handled at the edge — Cloudflare for external traffic, Traefik for local traffic.</p>
<hr>
<h2 id="the-hsts-detail">The HSTS detail</h2>
<p>Cloudflare likely has HSTS enabled for your domain. Browsers cache this: once they see an HSTS header for <code>hippotion.com</code>, they&rsquo;ll refuse to load any <code>http://</code> URL under that domain for the duration of the max-age. They silently upgrade to <code>https://</code> and fail if there&rsquo;s no cert.</p>
<p>This is why the original setup — tunnel only, no local cert — felt unreliable locally. The browser was doing the right thing (enforcing HTTPS) but the cert didn&rsquo;t exist. The wildcard cert fixes this because HTTPS now actually works locally. The HSTS enforcement is fine once TLS is real.</p>
<hr>
<h2 id="what-its-like-to-operate">What it&rsquo;s like to operate</h2>
<p>Adding a new service means writing one IngressRoute with two entrypoints and pushing to Git. No DNS records to create (cloudflared picks up hostnames from a config list), no certificates to request (the wildcard covers everything), no VPN profiles to distribute. The platform handles it.</p>
<p>Local access works when the internet is down. The Pi-hole DNS and the wildcard cert are entirely on-premises — as long as the server is up, the services are reachable, Cloudflare outage or not. I noticed this during a brief Cloudflare incident a few months ago: external access went down, everything inside the house kept working without interruption.</p>
<p>I&rsquo;m not a networking expert. I just followed the constraint — no open ports, no VPN — and the DNS-01 + split DNS solution fell out naturally. It turned out to be simpler to configure than the alternatives, and cleaner to operate.</p>
]]></content:encoded></item></channel></rss>