<?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>Kubernetes on bismigel</title><link>https://bismigel.dev/tags/kubernetes/</link><description>Recent content in Kubernetes on bismigel</description><generator>Hugo</generator><language>en-us</language><copyright>bismigel</copyright><lastBuildDate>Tue, 27 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://bismigel.dev/tags/kubernetes/index.xml" rel="self" type="application/rss+xml"/><item><title>Two tunnels are better than one: making the edge HA</title><link>https://bismigel.dev/posts/2026-10-27-two-tunnels-are-better-than-one/</link><pubDate>Tue, 27 Oct 2026 00:00:00 +0000</pubDate><guid>https://bismigel.dev/posts/2026-10-27-two-tunnels-are-better-than-one/</guid><description/><content:encoded><![CDATA[<blockquote>
<p><strong>Building the lab</strong> &mdash; part 8 of 8. ← <a href="2026-10-24-qsv-for-pods-and-the-subtitle-trap.org">Handing the GPU to a pod (and the subtitle trap)</a>
 · you're at the end.</p>
</blockquote>
<p>I only started worrying about the front door after something else broke and I saw how much depended on one box. One Cloudflare tunnel connector and one ingress controller, both on the same machine. If either one died, <strong>everything</strong> public went dark: the sites, the dashboards, the agent. I hadn't thought about it until then, which is usually how I find these things.</p>
<p>This is how I made the edge less fragile: two connectors, two ingresses, and a one-line routing change that lets either box answer. I'm writing it up partly because I'll forget, and partly because I didn't really understand it until I'd done it.</p>
<h1 id="the-shape-of-the-problem">The shape of the problem</h1>
<p>The lab is two single-node clusters. The &quot;hosting&quot; box runs ArgoCD, ingress-nginx and the `cloudflared` connector. The &quot;media&quot; box runs the media stack. Public hostnames come in through a Cloudflare Tunnel, and the tunnel's routing pointed at exactly one place:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">example</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>
  <pre tabindex="0"><code class="language-example" data-lang="example">ciceley.cc        -&gt; http://192.168.2.82:32080     # the hosting box&#39;s ingress
bismigel.dev      -&gt; http://192.168.2.82:32080
...
jellyfin.bismigel.dev -&gt; http://192.168.2.81:30096</code></pre>
</div>
<p>So the ingress and the connector were both a single point of failure on `.82`. A node failure, a crash, or even a bad rollout of either one took the whole edge down with it. Two things I'd been treating as separate turned out to fail together.</p>
<h1 id="the-plan">The plan</h1>
<p>Once I saw it clearly, the fix was less clever than I expected. Three parts:</p>
<ol>
<li>Run a <strong><strong>second `cloudflared` connector</strong></strong> on the media box. Cloudflare balances a tunnel across all its connectors, so two connectors means the tunnel survives one of them dying. This was the bit I had to go read up on.</li>
<li>Run a <strong><strong>second ingress-nginx</strong></strong> on the media box, on the <strong>same</strong> NodePort (`32080`) as the first.</li>
<li>Point each public host at the connector's <strong><strong>local</strong></strong> ingress (`http://127.0.0.1:32080`) instead of a hard-coded peer IP.</li>
</ol>
<p>The last one is the part that made it click, and I didn't trust it until I watched it work. A tunnel's ingress rules name a service. With `127.0.0.1`, that service is the connector's <strong>own</strong> machine. So both boxes can serve, and Cloudflare picks whichever connector is alive. Once I understood that, the rest of the design made sense.</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="c"># tunnel ingress (remote-managed, set via the API)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><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">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ciceley.cc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://127.0.0.1:32080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">bismigel.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://127.0.0.1:32080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div>
</div>
<p>Drawn out, it looks like this. I'm including the part that doesn't work yet, because that's the honest part:</p>
<pre class="mermaid" id="mermaid-2"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-2"] = "flowchart LR\n  user([Internet]) --\u003e cf[Cloudflare edge]\n  cf --\u003e c82[cloudflared\u003cbr/\u003e.82]\n  cf --\u003e c81[cloudflared\u003cbr/\u003e.81]\n\n  subgraph b82[\".82 - hosting box\"]\n    c82 --\u003e i82[ingress-nginx\u003cbr/\u003e:32080]\n    i82 --\u003e apps[sites - APIs - agent]\n  end\n\n  subgraph b81[\".81 - media box\"]\n    c81 --\u003e i81[ingress-nginx\u003cbr/\u003e:32080]\n    i81 -.-\u003e|no app backends yet| gap[\"apps still live on .82\"]\n  end";</script>

<h1 id="doing-it-the-parts-that-matter">Doing it (the parts that matter)</h1>
<p><strong><strong>The second connector.</strong></strong> Both boxes run cloudflared as a NixOS service off the same tunnel credentials (kept in SOPS). The media box had none, so I just had to add the service and the secret. This is the part I was most nervous about, because it meant touching the media box, which I've grown protective of.</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">services</span><span class="o">.</span><span class="n">cloudflared</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="n">tunnels</span><span class="o">.</span><span class="s2">&#34;&lt;tunnel-id&gt;&#34;</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">credentialsFile</span> <span class="o">=</span> <span class="s2">&#34;/root/.cloudflared/&lt;tunnel-id&gt;.json&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">default</span> <span class="o">=</span> <span class="s2">&#34;http_status:404&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">ingress</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;ciceley.cc&#34;</span> <span class="o">=</span> <span class="s2">&#34;http://127.0.0.1:32080&#34;</span><span class="p">;</span> <span class="cm">/* ... */</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="n">sops</span><span class="o">.</span><span class="n">secrets</span><span class="o">.</span><span class="s2">&#34;cloudflared_credentials&#34;</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">path</span> <span class="o">=</span> <span class="s2">&#34;/root/.cloudflared/&lt;tunnel-id&gt;.json&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="n">owner</span> <span class="o">=</span> <span class="s2">&#34;root&#34;</span><span class="p">;</span> <span class="n">mode</span> <span class="o">=</span> <span class="s2">&#34;0400&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span></span></span></code></pre></div>
</div>
<p>The tunnel is remote-managed, so the `ingress` map above is just a local fallback. The real routing lives in Cloudflare. I keep it roughly in sync anyway, because I don't want a surprise if I ever switch it to locally-managed.</p>
<p><strong><strong>The second ingress.</strong></strong> ingress-nginx is the same Helm chart, so the media cluster got its own Application pointing at the same chart and NodePorts. This was by far the easiest part, and it showed me why the GitOps setup is nice. The copy is a ten-line file, because it's the same manifest with a different `destination`.</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">source</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">repoURL</span><span class="p">:</span><span class="w"> </span><span class="l">https://kubernetes.github.io/ingress-nginx</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">chart</span><span class="p">:</span><span class="w"> </span><span class="l">ingress-nginx</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">helm</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">values</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      controller:
</span></span></span><span class="line"><span class="cl"><span class="sd">        service:
</span></span></span><span class="line"><span class="cl"><span class="sd">          type: NodePort
</span></span></span><span class="line"><span class="cl"><span class="sd">          nodePorts: { http: 32080, https: 32443 }</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">destination</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">calamansi-intel       </span><span class="w"> </span><span class="c"># the other cluster</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">ingress-nginx</span></span></span></code></pre></div>
</div>
<p><strong><strong>The routing.</strong></strong> Finally the tunnel config, which flips every public hostname from `http://192.168.2.82:32080` to `http://127.0.0.1:32080`. One API call.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</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-shell" data-lang="shell"><span class="line"><span class="cl">curl -X PUT <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s2">&#34;https://api.cloudflare.com/client/v4/accounts/</span><span class="nv">$ACC</span><span class="s2">/cfd_tunnel/</span><span class="nv">$TID</span><span class="s2">/configurations&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer </span><span class="nv">$CF</span><span class="s2">&#34;</span> --data @tunnel-cfg.json</span></span></code></pre></div>
</div>
<h1 id="the-honest-caveat">The honest caveat</h1>
<p>This makes the <strong>edge</strong> highly available: the tunnel and the ingress survive a failure of either. It does <strong><strong>not</strong></strong> make the <strong>apps</strong> highly available, and it took me longer than I'd like to admit to notice that. The sites and dashboards still run on the hosting box. If that whole node dies, the surviving connector reaches the media box's ingress, which has no matching Ingress resource and returns 404. The edge being up isn't enough for the apps to be up, and I'd rather write that down than let the post suggest something it doesn't do.</p>
<p>To go further I'd put the (stateless) apps on both clusters so either ingress has a backend. That's a bigger job, and I'm not sure I want the apps to work the same on both clusters yet. It's a decision I want to make on purpose rather than in a hurry.</p>
<h1 id="what-it-buys">What it buys</h1>
<p>For a homelab this is the right place to stop for now. The failures I actually expect (a crash-looping connector, a bad ingress rollout, a process dying) now mean &quot;one box serves instead of two&quot; instead of &quot;the whole edge is down&quot;. The tunnel shows two healthy connections, the sites answer, and the front door stops being the thing I worry about. For ten lines of YAML and one `curl`, that feels like a fair trade. And it's the first time an HA change has felt this small to me.</p>
<blockquote>
<p><strong>Building the lab</strong> &mdash; part 8. Previous: <a href="2026-10-24-qsv-for-pods-and-the-subtitle-trap.org">Handing the GPU to a pod (and the subtitle trap)</a>
.</p>
</blockquote>
]]></content:encoded></item><item><title>Handing the GPU to a pod (and the subtitle trap)</title><link>https://bismigel.dev/posts/2026-10-24-qsv-for-pods-and-the-subtitle-trap/</link><pubDate>Sat, 24 Oct 2026 00:00:00 +0000</pubDate><guid>https://bismigel.dev/posts/2026-10-24-qsv-for-pods-and-the-subtitle-trap/</guid><description/><content:encoded><![CDATA[<blockquote>
<p><strong>Building the lab</strong> &mdash; part 7 of 8. ← <a href="2026-10-20-an-agent-for-the-lab.org">An agent for the lab, on a leash</a>
 · <a href="2026-10-27-two-tunnels-are-better-than-one.org">Two tunnels are better than one →</a>
</p>
</blockquote>
<p>Two things broke my media box in the same week, and it took me a while to see they were the same problem. One was the GPU: the pod could see the graphics device but couldn't use it. The other was subtitles: the file had them, the player refused them, and the stream died. In both cases the thing was <strong>there</strong> but not <strong>usable</strong>, and the difference was one setting in a place I wasn't looking. I only figured that out afterwards.</p>
<p>These are my notes on both, mostly so I don't have to work them out twice.</p>
<h1 id="part-one-give-the-pod-the-gpu">Part one: give the pod the GPU</h1>
<p>The media box has an Intel i5 with a built-in GPU. As far as I can tell, the only real reason it earns its place is QuickSync: it can transcode video in hardware, so a Jellyfin stream should cost a few percent of the GPU instead of a whole CPU. I'm taking that on faith a bit, but it's why I bought this box.</p>
<p>On the host everything <strong>looks</strong> ready. The kernel driver is loaded and the device nodes exist.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</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-shell" data-lang="shell"><span class="line"><span class="cl">$ ls -l /dev/dri
</span></span><span class="line"><span class="cl">crw-rw---- <span class="m">1</span> root video 226,   <span class="m">0</span> card0
</span></span><span class="line"><span class="cl">crw-rw-rw- <span class="m">1</span> root render 226, <span class="m">128</span> renderD128</span></span></code></pre></div>
</div>
<p>So the obvious move is the one every guide gives you: mount /dev/dri into the pod and call it done.</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">volumeMounts</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">dri</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mountPath</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/dri</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</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">dri</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">hostPath</span><span class="p">:</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="l">/dev/dri</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">Directory</span></span></span></code></pre></div>
</div>
<p>The device shows up inside the pod. And then the transcode runs on the CPU anyway. ffmpeg pins a core and a half, and the VAAPI driver in the logs just says it can't find a display:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</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-shell" data-lang="shell"><span class="line"><span class="cl">No VA display found <span class="k">for</span> device /dev/dri/renderD128</span></span></code></pre></div>
</div>
<p>For a while I assumed that message meant what it said and went looking at drivers. It doesn't. The device is there. The drivers are there. The real problem is that <strong>seeing a device isn't the same as being allowed to open it</strong>. The thing doing the allowing is the device cgroup, not the file permissions. A hostPath mount puts the node inside the container's filesystem. It does nothing to the container's device access list. So the open comes back EPERM, the driver reports &quot;no display&quot;, and you go hunting in the wrong place. That took me an embarrassingly long time to figure out.</p>
<p>I proved it to myself the cheap way, just by opening the node:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</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-shell" data-lang="shell"><span class="line"><span class="cl"><span class="c1"># on the host</span>
</span></span><span class="line"><span class="cl">$ <span class="o">(</span> <span class="nb">exec</span> 3&lt;&gt;/dev/dri/renderD128 <span class="o">&amp;&amp;</span> <span class="nb">echo</span> OK <span class="o">)</span>
</span></span><span class="line"><span class="cl">OK
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># inside the pod</span>
</span></span><span class="line"><span class="cl">$ <span class="o">(</span> <span class="nb">exec</span> 3&lt;&gt;/dev/dri/renderD128 <span class="o">&amp;&amp;</span> <span class="nb">echo</span> OK <span class="o">)</span>
</span></span><span class="line"><span class="cl">sh: cannot create /dev/dri/renderD128: Operation not permitted</span></span></code></pre></div>
</div>
<p>Open on the host, denied in the pod. That one line was the whole diagnosis for me.</p>
<h2 id="the-fix">The fix</h2>
<p>There are two ways to hand the device to the container. The blunt one is to run the pod privileged:</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">containers</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">jellyfin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">securityContext</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">privileged</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>That grants every host device, which is more than the GPU and more than I'd like. But on a single-purpose media box it's the one-line answer, and it worked immediately. The tidier option is the Intel GPU device plugin, which registers the device as something you can request, so you ask for exactly it:</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">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">gpu.intel.com/i915</span><span class="p">:</span><span class="w"> </span><span class="m">1</span></span></span></code></pre></div>
</div>
<p>I went privileged first just to get it working and told myself I'd switch later. I haven't yet. I'm not sure that's the right call, and I'd rather say so than write it up like it was a decision.</p>
<p>That was only half of it, because the container was never the only gate. Jellyfin also has to be <strong>told</strong> to use the hardware, and by default it isn't. The encoding settings need a hardware acceleration type:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">json</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-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span> <span class="nt">&#34;HardwareAccelerationType&#34;</span><span class="p">:</span> <span class="s2">&#34;qsv&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;VaapiDevice&#34;</span><span class="p">:</span> <span class="s2">&#34;/dev/dri/renderD128&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;EnableHardwareEncoding&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span></span></span></code></pre></div>
</div>
<p>Then I checked the chain actually works instead of trusting a checkbox. The bundled ffmpeg has the QSV encoders, and the driver opens cleanly:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</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-shell" data-lang="shell"><span class="line"><span class="cl">$ ffmpeg -init_hw_device <span class="nv">qsv</span><span class="o">=</span>hw:/dev/dri/renderD128 ... -c:v h264_qsv -f null -
</span></span><span class="line"><span class="cl">libva info: va_openDriver<span class="o">()</span> returns <span class="m">0</span></span></span></code></pre></div>
</div>
<p><code>returns 0</code>{.verbatim} was the sentence I wanted to see. After that a transcode shows up with the right arguments and the CPU stops being the bottleneck:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</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-shell" data-lang="shell"><span class="line"><span class="cl">-init_hw_device <span class="nv">vaapi</span><span class="o">=</span>va:...,driver<span class="o">=</span>iHD
</span></span><span class="line"><span class="cl">-init_hw_device <span class="nv">qsv</span><span class="o">=</span>qs@va
</span></span><span class="line"><span class="cl">-codec:v:0 h264_qsv</span></span></code></pre></div>
</div>
<p>One more detail that cost me half a confused hour. The VAAPI drivers in the official Jellyfin image don't live in the system path. If you test by hand, you have to point at them yourself. Otherwise you get the same misleading &quot;no display&quot; error and think the fix didn't work.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</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-shell" data-lang="shell"><span class="line"><span class="cl"><span class="nv">LIBVA_DRIVERS_PATH</span><span class="o">=</span>/usr/lib/jellyfin-ffmpeg/lib/dri <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="nv">LIBVA_DRIVER_NAME</span><span class="o">=</span>iHD ffmpeg ...</span></span></code></pre></div>
</div>
<p>Jellyfin does that for you when it spawns its own ffmpeg. You only need to remember it when <strong>you're</strong> the one doing the spawn.</p>
<h1 id="part-two-the-subtitle-trap">Part two: the subtitle trap</h1>
<p>With transcoding finally cheap, I played an episode and hit the next wall. The moment I turned subtitles on, the player said they weren't supported and dropped the stream. I was genuinely confused, because I could <strong>see</strong> the subtitles were in the file.</p>
<p>They were. There were four of them, all English. The trouble was what kind they were:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</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-shell" data-lang="shell"><span class="line"><span class="cl">$ ffprobe -select_streams s -show_entries <span class="nv">stream</span><span class="o">=</span>codec_name ...
</span></span><span class="line"><span class="cl"><span class="nv">codec_name</span><span class="o">=</span>hdmv_pgs_subtitle
</span></span><span class="line"><span class="cl"><span class="nv">codec_name</span><span class="o">=</span>hdmv_pgs_subtitle</span></span></code></pre></div>
</div>
<p>PGS is an <strong>image</strong> format: each subtitle is a bitmap, not text. A browser can't draw that as a soft subtitle. The only way to show it is to burn it into the video, frame by frame, on the CPU, which is exactly the cost I'd just spent an afternoon removing. Worse, a web player that knows it can't render the stream sometimes doesn't fall back to burning it in at all. It just refuses.</p>
<p>The fix isn't to fight the image format. It's to add the text one. An external SRT is a soft subtitle, the browser draws it natively, and playback stays a remux. That's a job for Bazarr, which was already running and, it turned out, had never downloaded anything, because I'd never set it up.</p>
<h2 id="the-pitfalls-in-the-order-i-hit-them">The pitfalls, in the order I hit them</h2>
<p>Setting up Bazarr looked like a form to fill in. It was a small obstacle course, and each failure taught me something I would have had to learn eventually.</p>
<ol>
<li>
<p><strong>It couldn't reach Sonarr.</strong> The error was <code>Bad Request - Invalid Hostname</code>{.verbatim}. That isn't about connecting; it's about the <code>Host</code>{.verbatim} header. The *arr apps only answer to hostnames on their allow-list, and the service name Bazarr was using wasn't on it. It's obvious looking back, but the message says nothing about the *arr config.</p>
</li>
<li>
<p><strong>Its settings API silently ignores you.</strong> Posting a neat JSON blob returned a success code and changed nothing. The endpoint doesn't take a blob. It takes individual form fields, dash-joined, one per setting. This is the one that taught me a 204 isn't proof anything happened.</p>
</li>
<li>
<p><strong>A default profile breaks the database.</strong> Setting a default language profile made every new series fail to insert with <code>FOREIGN KEY constraint failed</code>{.verbatim}. The workaround is to leave the default unset and assign the profile directly. It's a bug in this Bazarr build, but the workaround is small enough to live with.</p>
</li>
<li>
<p><strong>It thought there was nothing to do.</strong> With the connection working and the provider logged in, the search found nothing, because Bazarr counts the <strong>embedded</strong> subtitles as already covering the language. The file did have English subtitles, after all. They were just the PGS ones the browser can't use. So the fix was to tell it to ignore the embedded and image-based tracks, so it would treat English as genuinely missing. Then it fetched SRTs, and they landed next to the files.</p>
</li>
</ol>
<p>This was the same lesson as the GPU, and I didn't see it the first time. &quot;The file has subtitles&quot; and &quot;the player can use the subtitles&quot; are two different things. The software will happily say the first is true while failing the second.</p>
<h2 id="what-it-bought">What it bought</h2>
<p>An SRT next to the file makes the whole chain cheap. The stream is no longer transcoded to burn in bitmaps. It's remuxed, the soft subtitle rides alongside, and the CPU goes back to sleep. The same episode that wanted three cores and a software overlay now barely registers.</p>
<p>So both halves ended the same way: a device that was there but not allowed, and a subtitle that was there but couldn't be shown. Each one was fixed by finding the single gate that actually mattered. The GPU is now usable, the subtitles are text, and the box is quiet again.</p>
<blockquote>
<p><strong>Building the lab</strong> &mdash; part 7. Previous: <a href="2026-10-20-an-agent-for-the-lab.org">An agent for the lab, on a leash</a>
. Next: <a href="2026-10-27-two-tunnels-are-better-than-one.org">Two tunnels are better than one</a>
.</p>
</blockquote>
]]></content:encoded></item></channel></rss>