<?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>Cloudflare on bismigel</title><link>https://bismigel.dev/tags/cloudflare/</link><description>Recent content in Cloudflare 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/cloudflare/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></channel></rss>