<?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>Network on bismigel</title><link>https://bismigel.dev/tags/network/</link><description>Recent content in Network on bismigel</description><generator>Hugo</generator><language>en-us</language><copyright>bismigel</copyright><lastBuildDate>Thu, 22 Oct 2026 22:15:00 -0400</lastBuildDate><atom:link href="https://bismigel.dev/tags/network/index.xml" rel="self" type="application/rss+xml"/><item><title>IPv6 packet loss, not slow DNS: why Jellyfin buffered off the LAN</title><link>https://bismigel.dev/posts/2026-10-22-ipv6-loss-not-slow-dns/</link><pubDate>Thu, 22 Oct 2026 22:15:00 -0400</pubDate><guid>https://bismigel.dev/posts/2026-10-22-ipv6-loss-not-slow-dns/</guid><description/><content:encoded><![CDATA[<p>Buffering from outside the LAN is the kind of report that invites guessing. The usual suspects are the transcode, the disk, or the network. This time the report came with a detail that did not fit any of them: the site felt slow &ldquo;only at DNS&rdquo;, and from a machine on <code>192.168.2.0/24</code> everything was fast.</p>
<p>That detail was the clue. DNS was not slow. One address family was losing packets, and everything else followed from that.</p>
<h1 id="the-report">The report</h1>
<p>The user was streaming from a remote network. Playback started, stalled after a few seconds, restarted, and stalled again. From inside the LAN the same file played normally.</p>
<p>The first instinct is to blame the server. The server was idle:</p>
<ul>
<li>node CPU 41% of 4 cores, load 2.56, iowait 3.7%;</li>
<li>disk utilisation 1.9% on NVMe and 1.45% on the media disk;</li>
<li>Jellyfin using 0.51 cores and 783 MiB of a 4 GiB limit;</li>
<li>about 1.8 Mbit/s on the wire, which is exactly one stream.</li>
</ul>
<p>Nothing on the box was the constraint. The Jellyfin log showed the real shape of the failure: ffmpeg started and was stopped a dozen times in six minutes, and every stop was clean (<code>FFmpeg exited with code 0</code>). The server was not crashing. The client was giving up and asking again.</p>
<h1 id="measure-both-families">Measure both families</h1>
<p>The remote client was dual-stack. I asked for the same request over each family on that machine:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -4 -sk -o /dev/null -w <span class="s2">&#34;v4 dns=%{time_namelookup} tcp=%{time_connect} ttfb=%{time_starttransfer}\n&#34;</span> https://jellyfin.bismigel.dev/System/Info/Public
</span></span><span class="line"><span class="cl">curl -6 -sk -o /dev/null -w <span class="s2">&#34;v6 dns=%{time_namelookup} tcp=%{time_connect} ttfb=%{time_starttransfer}\n&#34;</span> https://jellyfin.bismigel.dev/System/Info/Public</span></span></code></pre></div>
</div>
<p>One pair said IPv6 was slow. That was not the whole story, so we ran it five more times:</p>
<table>
	<thead>
			<tr>
					<th>run</th>
					<th>v4 dns</th>
					<th>v4 tcp</th>
					<th>v4 ttfb</th>
					<th>v6 dns</th>
					<th>v6 tcp</th>
					<th>v6 ttfb</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>0.013</td>
					<td>0.023</td>
					<td>0.094</td>
					<td>0.015</td>
					<td>1.044</td>
					<td>1.246</td>
			</tr>
			<tr>
					<td>2</td>
					<td>0.015</td>
					<td>0.025</td>
					<td>0.185</td>
					<td>0.015</td>
					<td>0.025</td>
					<td>0.240</td>
			</tr>
			<tr>
					<td>3</td>
					<td>0.016</td>
					<td>0.026</td>
					<td>0.096</td>
					<td>0.012</td>
					<td>0.022</td>
					<td>0.169</td>
			</tr>
			<tr>
					<td>4</td>
					<td>0.012</td>
					<td>0.022</td>
					<td>0.134</td>
					<td>0.011</td>
					<td>0.021</td>
					<td>0.302</td>
			</tr>
			<tr>
					<td>5</td>
					<td>0.012</td>
					<td>0.023</td>
					<td>0.078</td>
					<td>5.016</td>
					<td>5.027</td>
					<td>5.215</td>
			</tr>
			<tr>
					<td>6</td>
					<td>0.012</td>
					<td>0.022</td>
					<td>0.151</td>
					<td>0.011</td>
					<td>0.022</td>
					<td>0.192</td>
			</tr>
	</tbody>
</table>
<p>Look at runs 2, 3, 4 and 6: IPv6 is as fast as IPv4. Twenty milliseconds to connect, both families. Then run 1 and run 5 are one and five seconds.</p>
<p>That is not a slow path. That is loss with retransmission:</p>
<ul>
<li><code>tcp=1.044</code> is a lost SYN, retried on the kernel backoff;</li>
<li><code>dns=5.016</code> is a DNS query that was sent, dropped, and retried on the resolver&rsquo;s five second timer. Nothing had been sent to Cloudflare yet.</li>
</ul>
<p>My first framing was wrong. I called it a constant 45x penalty after one run. Six runs turned it into intermittent loss, which is a different failure and a worse one for video, because it breaks in bursts instead of being uniformly slow.</p>
<h1 id="why-it-looked-like-slow-dns">Why it looked like slow DNS</h1>
<p>Every hostname in the zone is a proxied CNAME to the tunnel. With Cloudflare IPv6 compatibility on, the zone answers both families for all of them:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">A     172.64.80.1                              TTL 282
</span></span><span class="line"><span class="cl">AAAA  2606:4700:130:436c:6f75:6466:6c61:7265   TTL 282</span></span></code></pre></div>
</div>
<p>A dual-stack client will prefer AAAA. Browsers handle a bad IPv6 path with Happy Eyeballs: they race both families and drop the loser after about 250 ms. Most native apps do not. Jellyfin&rsquo;s web client is a browser, but its TV and mobile clients are not, and neither are the small tools people test with.</p>
<p>So the client sat in the timeout, the user saw a spinner, and the natural report is &ldquo;DNS is slow&rdquo;. For one second or five seconds at a time, that is exactly what it was.</p>
<h1 id="why-loss-becomes-buffering">Why loss becomes buffering</h1>
<p>TCP throughput falls roughly as <code>1 / (RTT * sqrt(loss))</code>. One percent loss is not a small tax; it is close to an order of magnitude on a long path.</p>
<p>Jellyfin&rsquo;s HLS player measures the throughput it gets and picks a bitrate from it. On the lossy path it measured about 1 Mbit/s and locked into a 720p transcode:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">02:11:28  -b:v 1116000  ... scale_vaapi=w=1280:h=720 ...</span></span></code></pre></div>
</div>
<p>Every stall also killed the HLS session. The next request started a new ffmpeg job, which is the restart storm in the log. The server was doing exactly what it was told; the client could not pull the segments fast enough to keep the session alive.</p>
<h1 id="the-shape-of-the-path">The shape of the path</h1>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">dual-stack client
</span></span><span class="line"><span class="cl">      |
</span></span><span class="line"><span class="cl">      |  1. DNS: A and AAAA, both answered (proxied CNAME to the tunnel)
</span></span><span class="line"><span class="cl">      v
</span></span><span class="line"><span class="cl">Cloudflare anycast     172.64.80.1        /  2606:4700:130:436c:...
</span></span><span class="line"><span class="cl">      |                      |                        |
</span></span><span class="line"><span class="cl">      |  2. client prefers AAAA  -&gt;  loss here -&gt; 1 s connect, 5 s DNS retry
</span></span><span class="line"><span class="cl">      |                      |                        |
</span></span><span class="line"><span class="cl">      |  3. tunnel connector (cloudflared on .81 and .82, IPv4/QUIC)
</span></span><span class="line"><span class="cl">      v
</span></span><span class="line"><span class="cl">ingress-nginx :32080  -&gt;  Jellyfin pod on .81  -&gt;  /mnt/media</span></span></code></pre></div>
</div>
<p>The tunnel is not part of the problem. The connector talks to Cloudflare over IPv4 either way. What changes is which family the client uses to reach the edge.</p>
<h1 id="the-fix-at-the-edge-not-on-the-client">The fix at the edge, not on the client</h1>
<p>There is no per-hostname switch for this. While IPv6 compatibility is on, Cloudflare serves AAAA for every proxied record in the zone, and a proxied record cannot be made A-only. The options were:</p>
<ol>
<li>fix every client network that has bad IPv6, forever;</li>
<li>turn the zone&rsquo;s IPv6 off, once.</li>
</ol>
<p>For this lab the second one costs nothing. Everything public is a Cloudflare anycast front door in front of a tunnel. No service here needs IPv6, and any client that worked was already falling back to IPv4.</p>
<p>The OpenTofu change is four lines, next to the other zone settings:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;cloudflare_zone_setting&#34; &#34;ipv6&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  zone_id</span>    <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">zone_id</span>
</span></span><span class="line"><span class="cl"><span class="n">  setting_id</span> <span class="o">=</span> <span class="s2">&#34;ipv6&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  value</span>      <span class="o">=</span> <span class="s2">&#34;off&#34;</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>That is PR <code>bakuran-infra</code> #69 (private repository). The change went in through <code>tofu apply</code>, not through the dashboard, so the reason lives in the repo next to the setting.</p>
<p>One thing the documentation does not say clearly: Cloudflare&rsquo;s docs present turning IPv6 compatibility off as an Enterprise option, and suggest Pseudo IPv4 for everyone else. On this account the API accepted <code>ipv6 = off</code> and the AAAA answers stopped. I verified the result instead of trusting the plan:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -H <span class="s1">&#39;accept: application/dns-json&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s1">&#39;https://cloudflare-dns.com/dns-query?name=jellyfin.bismigel.dev&amp;type=AAAA&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># returns no Answer section after the change</span></span></span></code></pre></div>
</div>
<h1 id="what-it-bought">What it bought</h1>
<p>The client had an AAAA cached for the TTL (282 seconds) and an open HTTP/2 connection, so nothing changed at once. When the page was reloaded, the session re-authenticated and the source address changed from <code>2a02:6ea0:...</code> to <code>185.98.171.223</code>. Two things happened in the same minute:</p>
<table>
	<thead>
			<tr>
					<th>metric</th>
					<th>before</th>
					<th>after</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>source family</td>
					<td>IPv6</td>
					<td>IPv4</td>
			</tr>
			<tr>
					<td>target bitrate</td>
					<td>1116000</td>
					<td>9753335</td>
			</tr>
			<tr>
					<td>downscale</td>
					<td>720p</td>
					<td>none</td>
			</tr>
			<tr>
					<td>transcoder restarts</td>
					<td>every 10 to 30 seconds</td>
					<td>one per user seek</td>
			</tr>
	</tbody>
</table>
<p>The bitrate went up 8.7x, and the <code>w=1280:h=720</code> downscale disappeared from the ffmpeg command. The stream was not only smoother, it was higher quality. The last restart in the log was a seek to 00:18:46, which is a user action, not a failure.</p>
<p>That is the part I did not expect. I thought removing the lossy path would stop the buffering. It also let the player re-measure the link and take 9.75 Mbit/s instead of 1.1 Mbit/s, because the loss was what made it choose the low ladder in the first place.</p>
<h1 id="the-second-limit-underneath-it">The second limit underneath it</h1>
<p>With the path fixed, the next test was a 4K file: <code>Hush (2016)</code>, 2160p HDR HEVC. This time the ffmpeg line said something else:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">02:20:03  -b:v 11616000  scale_vaapi=w=2560:h=1072
</span></span><span class="line"><span class="cl">          procamp_vaapi, tonemap_vaapi=format=nv12:p=bt709:t=bt709:m=bt709
</span></span><span class="line"><span class="cl">02:20:05  MediaInfoHelper: RemoteClientBitrateLimit: 12000000 ...</span></span></code></pre></div>
</div>
<p>The stream was being tone-mapped from HDR to SDR and re-encoded at 11.6 Mbit/s, not because of the network this time but because of a server default: the Internet streaming bitrate limit of 12 Mbit/s. It applies to every client Jellyfin considers remote, and it caps direct play as well as transcoding. The client could ask for 40 Mbit/s and still get 11.6.</p>
<p>That value is not a Kubernetes field. It lives in <code>config/system.xml</code> on the <code>jellyfin-config</code> PVC, which is why nobody sees it in the repo. It is removed by a one-shot Job that calls the API and verifies the result: <code>bakuran-gitops</code> PR #151.</p>
<p>After it ran, the same file started at 31.7 Mbit/s, still tone-mapping but with no downscale:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">02:37:06  -b:v 31673882  procamp_vaapi, tonemap_vaapi ...</span></span></code></pre></div>
</div>
<p>Two lessons from that: a transport fix can expose a policy limit underneath it, and a setting that lives on a PVC is invisible until it bites.</p>
<h1 id="what-actually-limits-it-now">What actually limits it now</h1>
<p>With both caps gone, the honest ceiling list for this lab, measured rather than assumed:</p>
<table>
	<thead>
			<tr>
					<th>limit</th>
					<th>measured value</th>
					<th>what it means</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>node NIC</td>
					<td>1 Gbps each (<code>node_network_speed_bytes</code> 125000000)</td>
					<td>~940 Mbit/s per node, about 23 streams at 40 Mbit/s</td>
			</tr>
			<tr>
					<td>WAN upload</td>
					<td>3 Gbps</td>
					<td>wider than the NIC, so never the binding constraint</td>
			</tr>
			<tr>
					<td>CPU, 4K HDR tone-map at 31.7 Mbit/s</td>
					<td>0.80 cores per stream</td>
					<td>~4 concurrent before 4 cores saturate</td>
			</tr>
			<tr>
					<td>CPU, same at 11.6 Mbit/s downscaled</td>
					<td>0.58 cores per stream</td>
					<td>the cheaper shape before the cap was removed</td>
			</tr>
			<tr>
					<td>CPU, direct play</td>
					<td>near zero (<code>codec:v copy</code>)</td>
					<td>remux only, limit becomes disk and NIC</td>
			</tr>
	</tbody>
</table>
<p>The order matters. Direct play is cheap and the NIC decides. A 4K HDR title that needs tone-mapping is expensive and the CPU decides, long before any cable does. Four people watching the same HDR film now costs 3.2 of the 4 cores, so the fifth one queues. At the old 11.6 Mbit/s target the same four cost 2.3.</p>
<p>That is the real trade in raising the cap: better picture per stream, fewer streams before the box is full.</p>
<h1 id="what-i-would-do-differently">What I would do differently</h1>
<ul>
<li>Measure both address families before theorising. One pair of numbers looked like a constant penalty; six runs showed loss, which is a different diagnosis and a different fix.</li>
<li>Treat a 5 second DNS time as a dropped query, not a slow resolver. The number is a timer, not a measurement of distance.</li>
<li>Remember that the server can be idle while the user is unhappy. CPU, disk and memory were all clean during the worst of it.</li>
<li>Check the cache before declaring a fix live. The client kept using IPv6 for minutes after the zone stopped publishing AAAA, because both the answer and the connection were already open.</li>
<li>Keep going one layer down. Fixing the path made the transcodes cheap, which is what made the 12 Mbit/s policy cap visible in the first place.</li>
<li>Measure again after raising a cap. The CPU per stream went from 0.58 to 0.80 cores when the same 4K file stopped being downscaled, and that changes how many viewers fit.</li>
</ul>
<h1 id="links">Links</h1>
<ul>
<li>The edge change: <code>bakuran-infra</code> PR #69 (private repository)</li>
<li>The bitrate cap removal: <code>bakuran-gitops</code> PR #151 (private repository)</li>
<li>Cloudflare IPv6 compatibility: <a href="https://developers.cloudflare.com/network/ipv6-compatibility/" target="_blank" rel="noopener noreferrer">https://developers.cloudflare.com/network/ipv6-compatibility/</a>
</li>
<li>Cloudflare zone settings API: <a href="https://developers.cloudflare.com/api/resources/zones/subresources/settings/methods/edit/" target="_blank" rel="noopener noreferrer">https://developers.cloudflare.com/api/resources/zones/subresources/settings/methods/edit/</a>
</li>
<li>Happy Eyeballs, RFC 8305: <a href="https://www.rfc-editor.org/rfc/rfc8305" target="_blank" rel="noopener noreferrer">https://www.rfc-editor.org/rfc/rfc8305</a>
</li>
<li>The OpenTofu migration that made this a four line change: <a href="https://bismigel.dev/posts/2026-10-20-cloudflare-opentofu-migration/">https://bismigel.dev/posts/2026-10-20-cloudflare-opentofu-migration/</a>
</li>
</ul>
]]></content:encoded></item><item><title>Native vs encapsulated routing for a small homelab</title><link>https://bismigel.dev/posts/2026-10-19-native-vs-encapsulation/</link><pubDate>Mon, 19 Oct 2026 09:30:00 -0400</pubDate><guid>https://bismigel.dev/posts/2026-10-19-native-vs-encapsulation/</guid><description/><content:encoded><![CDATA[<p>Cilium gives you two main ways to move packets between nodes. The choice is small, but it decides what your network and firewall must do.</p>
<h1 id="the-short-version">The short version</h1>
<table>
	<thead>
			<tr>
					<th>Mode</th>
					<th>What moves packets</th>
					<th>Main requirement</th>
					<th>Best fit</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Native</td>
					<td>Linux routing</td>
					<td>PodCIDRs must be routable between nodes</td>
					<td>A network you control, often one L2 segment</td>
			</tr>
			<tr>
					<td>Encapsulation</td>
					<td>VXLAN inside UDP 8472</td>
					<td>Nodes only need IP reachability</td>
					<td>A simple lab, or nodes without PodCIDR routes</td>
			</tr>
	</tbody>
</table>
<p>Both keep kube-proxy in this lab. Cilium handles the CNI, policy and visibility; kube-proxy still handles ClusterIP services.</p>
<h1 id="native-routing">Native routing</h1>
<p>Native routing tells Cilium to hand non-local packets to the Linux routing table. With <code>autoDirectNodeRoutes</code>, each node learns a route to every other node&rsquo;s PodCIDR:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">routingMode</span><span class="p">:</span><span class="w"> </span><span class="l">native</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">autoDirectNodeRoutes</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="nt">ipv4NativeRoutingCIDR</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;10.42.0.0/16&#34;</span></span></span></code></pre></div>
</div>
<p>Good:</p>
<ul>
<li>no encapsulation overhead;</li>
<li>full MTU;</li>
<li>simple packet capture and routing inspection.</li>
</ul>
<p>Cost:</p>
<ul>
<li>the underlay must route PodCIDRs;</li>
<li>every node needs to learn the other nodes&rsquo; pod routes;</li>
<li>spanning multiple L2 or L3 domains usually means BGP or static routes.</li>
</ul>
<h1 id="encapsulation">Encapsulation</h1>
<p>VXLAN wraps the pod packet in a UDP packet between nodes. The underlay only needs normal node-to-node IP reachability:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">routingMode</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">tunnelProtocol</span><span class="p">:</span><span class="w"> </span><span class="l">vxlan</span></span></span></code></pre></div>
</div>
<p>Good:</p>
<ul>
<li>the simplest underlay requirement;</li>
<li>no PodCIDR routes on the physical network;</li>
<li>new nodes join without changing a router;</li>
<li>works well on a flat home LAN.</li>
</ul>
<p>Cost:</p>
<ul>
<li>about 50 bytes of overhead, so pod MTU drops to 1450 on a 1500 MTU LAN;</li>
<li>extra encapsulation and decapsulation work;</li>
<li>an extra failure surface: VXLAN device, UDP port and firewall rules.</li>
</ul>
<h1 id="what-this-lab-uses">What this lab uses</h1>
<p>VXLAN. All three nodes are on one LAN, but the goal is to keep the network configuration small. Native routing worked, but encapsulation lets the nodes join with only IP reachability and no PodCIDR routes outside the cluster.</p>
<p>The relevant settings are in <code>bakuran-gitops</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">routingMode</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">tunnelProtocol</span><span class="p">:</span><span class="w"> </span><span class="l">vxlan</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kubeProxyReplacement</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;false&#34;</span></span></span></code></pre></div>
</div>
<p>The NixOS hosts keep their firewall and trust the Cilium interfaces:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">nix</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nix" data-lang="nix"><span class="line"><span class="cl"><span class="n">networking</span><span class="o">.</span><span class="n">firewall</span><span class="o">.</span><span class="n">checkReversePath</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="n">networking</span><span class="o">.</span><span class="n">firewall</span><span class="o">.</span><span class="n">trustedInterfaces</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">&#34;cni+&#34;</span> <span class="p">];</span></span></span></code></pre></div>
</div>
<p><code>checkReversePath=false</code> removes the strict NixOS reverse-path chain that can drop VXLAN return traffic. It is necessary here, but it is not enough by itself.</p>
<h1 id="the-failure-that-cost-the-most-time">The failure that cost the most time</h1>
<p>VXLAN would not start. Cilium agents logged:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">failed to setup vxlan tunnel device:
</span></span><span class="line"><span class="cl">  address already in use</span></span></code></pre></div>
</div>
<p>The old Flannel <code>flannel.1</code> interface was still up and owned UDP 8472. Removing it and restarting Cilium made VXLAN work immediately:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ip link del flannel.1
</span></span><span class="line"><span class="cl">kubectl -n kube-system rollout restart ds/cilium</span></span></code></pre></div>
</div>
<p>Native routing had hidden the problem because it does not use VXLAN. A oneshot service now removes a stale <code>flannel.1</code> before k3s starts.</p>
<h1 id="what-to-test-after-switching">What to test after switching</h1>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ip -d link show <span class="nb">type</span> vxlan
</span></span><span class="line"><span class="cl">ss -lunp <span class="p">|</span> grep <span class="m">8472</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">kubectl run nettest --image<span class="o">=</span>curlimages/curl:8.10.1 --restart<span class="o">=</span>Never --command -- <span class="se">\
</span></span></span><span class="line"><span class="cl">  sh -c <span class="s1">&#39;curl -s -o /dev/null -w &#34;%{http_code}\n&#34; https://example.com&#39;</span></span></span></code></pre></div>
</div>
<p>Then check:</p>
<ul>
<li>new pods reach <code>Running</code>;</li>
<li>pod DNS works;</li>
<li>external egress works;</li>
<li>ClusterIP services work;</li>
<li>public ingress and LAN hostPort still work;</li>
<li><code>cilium-dbg status</code> shows <code>Tunnel [vxlan]</code> and health is 3/3.</li>
</ul>
<p>The full failure path is in <a href="/posts/2026-10-19-cilium-vxlan-troubleshooting/">Cilium VXLAN troubleshooting: port 8472 already in use</a>
.</p>
<p>The earlier migration story is in <a href="/posts/2026-10-18-homelab-network-migration/">Homelab network migration</a>
.</p>
<h1 id="sources">Sources</h1>
<ul>
<li>Cilium routing concepts: <a href="https://docs.cilium.io/en/stable/network/concepts/routing/" target="_blank" rel="noopener noreferrer">https://docs.cilium.io/en/stable/network/concepts/routing/</a>
</li>
<li>Cilium masquerading: <a href="https://docs.cilium.io/en/stable/network/concepts/masquerading/" target="_blank" rel="noopener noreferrer">https://docs.cilium.io/en/stable/network/concepts/masquerading/</a>
</li>
<li>NixOS rpfilter issue: <a href="https://github.com/lucas-albers-lz4/rke2nixos/issues/10" target="_blank" rel="noopener noreferrer">https://github.com/lucas-albers-lz4/rke2nixos/issues/10</a>
</li>
<li>fwmark collision case: <a href="https://pokgak.xyz/notes/cilium-vxlan-tailscale-fwmark-collision/" target="_blank" rel="noopener noreferrer">https://pokgak.xyz/notes/cilium-vxlan-tailscale-fwmark-collision/</a>
</li>
</ul>
]]></content:encoded></item><item><title>Homelab network migration: NetworkPolicies, egress and Cilium</title><link>https://bismigel.dev/posts/2026-10-18-homelab-network-migration/</link><pubDate>Sun, 18 Oct 2026 18:00:00 -0400</pubDate><guid>https://bismigel.dev/posts/2026-10-18-homelab-network-migration/</guid><description/><content:encoded><![CDATA[<blockquote>
<p><strong>Building the lab: afterword.</strong> (Not part of the numbered series.) ← <a href="/posts/2026-10-18-homelab-epilogue/">Homelab epilogue: exposing services and publishing the repo</a>
</p>
</blockquote>
<p>The epilogue was about deciding what to show. This post is about changing the network underneath it.</p>
<p>I wanted to take the useful ideas from a smaller reference homelab and apply them to Bakuran: isolate application namespaces, close egress where possible, split the admin path from the public path, and add SLOs. I also wanted Cilium and Hubble for flow visibility.</p>
<p>Most of that is live. Cilium was the hard part, and it is live now. The failure and the fix are the most useful part of the story.</p>
<h1 id="what-the-network-looked-like-before">What the network looked like before</h1>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "flowchart LR\n  U[\"Internet\"] --\u003e CF[\"Cloudflare edge\u003cbr/\u003eTLS, Access, Tunnel\"]\n  CF --\u003e T[\"Tunnel\u003cbr/\u003e2 connectors\"]\n  T --\u003e I[\"ingress-nginx\"]\n  I --\u003e A[\"apps\"]\n  I --\u003e B[\"books\"]\n  I --\u003e D[\"default / Jellyfin\"]\n  I --\u003e DL[\"downloads\"]\n  I --\u003e H[\"homepage\"]\n  I --\u003e IM[\"immich\"]\n  I --\u003e K[\"karakeep\"]\n  I --\u003e M[\"media\"]\n  I --\u003e Z[\"agent\"]\n  A \u003c--\u003e B\n  A \u003c--\u003e M\n  M \u003c--\u003e DL\n  Z \u003c--\u003e M\n  LAN[\"LAN TV\"] --\u003e D";</script>

<p>The only boundary was the edge. Inside the cluster, every pod could reach every other pod. Jellyfin was reachable on the LAN through hostPort 8096.</p>
<h1 id="what-it-looks-like-now">What it looks like now</h1>
<pre class="mermaid" id="mermaid-1"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-1"] = "flowchart LR\n  U[\"Internet\"] --\u003e CF[\"Cloudflare edge\u003cbr/\u003eTLS, Access, Tunnel\"]\n  CF --\u003e T[\"Tunnel\u003cbr/\u003e2 connectors\"]\n  T --\u003e I[\"ingress-nginx\"]\n  I --\u003e A[\"apps\"]\n  I --\u003e B[\"books\"]\n  I --\u003e D[\"default / Jellyfin\"]\n  I --\u003e DL[\"downloads\"]\n  I --\u003e H[\"homepage\"]\n  I --\u003e IM[\"immich\"]\n  I --\u003e K[\"karakeep\"]\n  I --\u003e M[\"media\"]\n  I --\u003e Z[\"agent\"]\n  M --\u003e B\n  M --\u003e D\n  M --\u003e DL\n  MON[\"monitoring\"] --\u003e D\n  MON --\u003e IM\n  Z --\u003e M\n  LAN[\"LAN 192.168.2.0/24\"] --\u003e|TCP 8096| D\n  X[\"other pods\"] -.-\u003e|denied| A";</script>

<p>Every application namespace now has an ingress-only NetworkPolicy. Same namespace and ingress-nginx are allowed. Cross-namespace rules are explicit. Egress is now default-deny as well, with DNS, local dependencies and public HTTP(S) allowed only where needed.</p>
<h1 id="the-one-shot-rollout">The one-shot rollout</h1>
<p>I did not roll this out one namespace at a time. I took a restore point first:</p>
<ul>
<li>git tags for the main repositories;</li>
<li>an etcd snapshot;</li>
<li>fresh credentials and appdata backups in R2.</li>
</ul>
<p>Then I applied the policies to the application namespaces in one pull request. The restore point was the permission to move quickly.</p>
<p>The first pilot was Karakeep. It had no Prometheus scrape annotations, no cross-namespace dependencies and no probes that could be blocked. The policy allowed same-namespace traffic and ingress-nginx only. The public page returned 200, Meilisearch worked, and a pod outside the namespace was refused.</p>
<p>Then the rest: apps, books, default, downloads, homepage, immich, karakeep, media and the agent.</p>
<h1 id="what-broke-and-what-it-taught-me">What broke, and what it taught me</h1>
<h2 id="jellyfin-on-the-lan">Jellyfin on the LAN</h2>
<p>The TV uses <code>http://192.168.2.81:8096</code>, not the public hostname. The first policy blocked the hostPort path. The fix was a narrow exception: the LAN CIDR, TCP 8096 only, in the <code>default</code> namespace. The public path still goes through the tunnel.</p>
<h2 id="login-watchers-and-nat">Login watchers and NAT</h2>
<p>The Immich and Jellyfin login watchers started failing after the rollout. Two things were happening:</p>
<ol>
<li>When a watcher landed on the media node, NAT masked the pod source IP. The destination policy saw a node IP, not the monitoring namespace.</li>
<li>A new pod could connect before kube-router had programmed the policy.</li>
</ol>
<p>The fix was to pin both watchers to the platform node and wait five seconds before the first query. They now complete on schedule.</p>
<h2 id="the-policy-programming-race">The policy programming race</h2>
<p>The first egress test from the <code>apps</code> namespace showed a false success. The pod started before the policy was programmed. A second test, after the policy had settled, was blocked. The lesson is simple: wait and test again before believing a policy is live.</p>
<h1 id="egress-not-just-ingress">Egress, not just ingress</h1>
<p>Ingress was the easy half. Egress has more destinations:</p>
<ul>
<li>DNS</li>
<li>the Kubernetes API</li>
<li>local services</li>
<li>public metadata and indexers</li>
<li>Ollama on the LAN</li>
<li>GitHub, DeepSeek, Telegram and Cloudflare</li>
</ul>
<p>I started with the static and API apps. They need DNS and same-namespace traffic, and nothing else. Then I moved to Karakeep and Immich. Karakeep kept Ollama and public HTTP(S); Immich kept public HTTP(S) for metadata and models. The media stack kept its local dependencies and public indexers. The downloaders kept public internet for trackers and Usenet.</p>
<p>Private ranges are denied except named dependencies. Egress is still open for the infrastructure namespaces; that is deliberate.</p>
<h1 id="slos">SLOs</h1>
<p>The monitoring stack now has seven public probes. The blackbox exporter runs inside the cluster and probes the public URL through Cloudflare and back through the tunnel. That measures the path a user takes, not just a pod.</p>
<p>The target is 99.5% availability over 7 days, with a 0.5% error budget. The recording rules track 5m, 1h, 6h and 7d ratios, and the alerting rules cover fast and slow burn. The Homelab SLOs dashboard shows the budget, per-target availability and probe duration.</p>
<p>The SLOs also caught a real bug: <code>alerts.yml</code> had been invalid since the Cloudflare alert group was added. Prometheus rejected the file, kept the old rules, and never loaded the edge or SLO alerts. The reload endpoint returned 500, but I had not checked it. Fixing the YAML indentation fixed both the edge alerts and the SLO rules.</p>
<h1 id="cilium-finally">Cilium, finally</h1>
<p>The Cilium failures were not a kernel bug. The old Flannel <code>flannel.1</code> interface was still up on every node and held UDP 8472. Cilium could not start its VXLAN device: <code>address already in use</code>. Native routing avoided the collision, so it looked like a kernel difference. After deleting the stale interface, VXLAN worked on the same kernel.</p>
<p>Production now uses VXLAN encapsulation with kube-proxy. The NixOS firewall trusts <code>cni+</code> and sets <code>checkReversePath=false</code>. A oneshot removes a stale <code>flannel.1</code> before k3s starts.</p>
<p>The full comparison is in <a href="/posts/2026-10-19-native-vs-encapsulation/">Native vs encapsulation</a>
. The failure path is in <a href="/posts/2026-10-19-cilium-vxlan-troubleshooting/">the VXLAN troubleshooting guide</a>
.</p>
<h1 id="lessons">Lessons</h1>
<ol>
<li><strong><strong>A CNI swap needs a disposable cluster.</strong></strong> Single-node validation is useful, but it does not prove a multi-node path.</li>
<li><strong><strong>Pod-to-pod working is not enough.</strong></strong> Test ClusterIP, NodePort, the API, external egress and hostPort.</li>
<li><strong><strong>CNI uninstall is not clean.</strong></strong> Check iptables, ip rules and BPF state before restoring the old CNI.</li>
<li><strong><strong>Egress is harder than ingress.</strong></strong> Ingress has a closed source set; egress has a long destination list.</li>
<li><strong><strong>HostPort needs explicit CNI support.</strong></strong> Cilium&rsquo;s CNI config needed <code>portmap</code> for the TV path.</li>
<li><strong><strong>Reboot order matters for NFS.</strong></strong> If <code>.81</code> boots before <code>.82</code>, the media branches fail to mount and the books pods stay in <code>ContainerCreating</code>.</li>
<li><strong><strong>A false-positive test is worse than no test.</strong></strong> Wait for policy programming before concluding that a rule works.</li>
<li><strong><strong>Validate alert rules before reloading.</strong></strong> A YAML error kept Prometheus on the old rule set and hid the new alerts.</li>
</ol>
<h1 id="what-is-next">What is next</h1>
<p>Cilium now runs with VXLAN, and the trust-zone edge is live. The remaining work is less about the packet path:</p>
<ul>
<li>finish the full DR rehearsal;</li>
<li>close the image pinning gap;</li>
<li>rotate the overprivileged Cloudflare token;</li>
<li>keep the backup and alert checks honest.</li>
</ul>
<p>The lab is no longer avoiding the network. It is running it.</p>
<blockquote>
<p><strong>Building the lab: afterword.</strong> Previous: <a href="/posts/2026-10-18-homelab-epilogue/">Homelab epilogue: exposing services and publishing the repo</a>
.</p>
</blockquote>
]]></content:encoded></item></channel></rss>