<?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/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Zero Trust · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/zero-trust/</link>
    <description>Short stories of value on software engineering and engineering leadership — performance reviews, hiring and scaling teams, CI/CD, DevOps monitoring and agentic AI. Written from Seoul.</description>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>© 2026 Jared Lynskey</copyright>
    <lastBuildDate>Thu, 01 Oct 2026 00:00:00 +1200</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/zero-trust/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>A Private Network for One: Cloudflare WARP, Tunnels and the Machines I Build On</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-01-cloudflare-warp-dev-network/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/2026-10-01-cloudflare-warp-dev-network/</guid>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>WARP</category>
      <category>Zero Trust</category>
      <category>Cloudflare Tunnel</category>
      <category>Networking</category>
      <category>SSH</category>
      <category>DevOps</category>
      <category>AI Agents</category>
      <category>Ollama</category>
      <category>Mobile Development</category>
      <category>Expo</category>
      <category>IPv6</category>
      <description>I build on a MacBook Air, but the heavy work (local models, fine-tuning, a k3s cluster, agent runs) lives on a workstation in the office that has no public name, and I test on a phone that&#39;s usually on 5G. Cloudflare WARP and two Cloudflare Tunnels turned the three into one private network: fixed addresses in a range nothing else uses, public HTTPS for dev servers behind an Access login, and dashboards that never touch the open internet. Here&#39;s how it&#39;s put together, from local HTTPS on `.test` names through Caddy and a SwiftBar menu that shows what&#39;s up, to why the addresses are 198.18.0.x and fd00:198:18::x, and the four things that bit me on the way.</description>
      <content:encoded><![CDATA[<p>My working setup is three machines that are rarely in the same place. There&rsquo;s a MacBook Air, which goes wherever I go. There&rsquo;s a workstation in the office called devbox, with a real GPU, which runs Ollama, Unsloth Studio for fine-tuning, a small k3s cluster with its own container registry, and whatever agent I&rsquo;ve left grinding through a long job. And there&rsquo;s my phone, which is where the apps I build actually get used, and which spends most of its day on mobile data rather than wifi.</p>
<p>For a long time those three didn&rsquo;t really talk to each other. The laptop could reach devbox when it was on the office LAN and not otherwise. The phone could reach my laptop&rsquo;s dev server when both were on the same wifi, and then only if I remembered to bind to <code>0.0.0.0</code> and typed in whatever DHCP had given the Mac that morning. Everything interesting on the workstation, the Ollama API, the Traefik dashboard, the traces, the registry, was bound to localhost or to the cluster network, deliberately, because none of it belongs on the internet.</p>
<p>This post is how I joined them up with Cloudflare WARP and a pair of Cloudflare Tunnels, what it changed about how I build AI features and mobile apps, and the handful of things that broke without telling me.</p>

<h2 class="relative group">What I actually wanted
    <div id="what-i-actually-wanted" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-i-actually-wanted" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Stated as requirements, it&rsquo;s short:</p>
<ol>
<li>The laptop should be a thin client. Anything that needs a GPU, a lot of RAM or hours of runtime should run on devbox, and the laptop should reach it the same way from the office, from home and from a café.</li>
<li>The phone should reach my dev servers and my shells from anywhere, on mobile data, without a VPN I have to remember to switch on and off.</li>
<li>The things I use to see how a system is behaving, dashboards, traces, <code>docker info</code>, model lists, should be reachable by me and nobody else, and should never get a public URL.</li>
<li>No inbound ports. Not on the office router, not on my home router, not anywhere.</li>
</ol>
<p>The fourth one rules out the old answer, which is a port forward and a dynamic DNS name. It also rules out a lot of cleverness: both devbox and my laptop sit behind NAT I don&rsquo;t control, and the laptop is behind a different NAT every few hours.</p>

<h2 class="relative group">Before: a repo of SSH tunnels
    <div id="before-a-repo-of-ssh-tunnels" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#before-a-repo-of-ssh-tunnels" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The first version of this was a small repo I called Automatic Tunnel, and it&rsquo;s still the base layer. It solves the &ldquo;clean names for things I reach over SSH&rdquo; problem on the laptop and nothing more. dnsmasq answers <code>*.test</code> with <code>127.0.0.1</code>, Caddy routes by hostname to a local port with <code>tls internal</code> so the browser is happy, and a set of autossh launch agents hold the SSH forwards open and reconnect when they drop. The hostnames say where a thing lives:</p>
<table>
	<thead>
			<tr>
					<th>Suffix</th>
					<th>Means</th>
					<th>Carried by</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>*.local.test</code></td>
					<td>this Mac</td>
					<td>nothing, it&rsquo;s a dev server</td>
			</tr>
			<tr>
					<td><code>*.ssh.test</code></td>
					<td>a server</td>
					<td>an autossh forward</td>
			</tr>
			<tr>
					<td><code>*.fly.test</code></td>
					<td>Fly.io</td>
					<td><code>fly proxy</code> over WireGuard</td>
			</tr>
	</tbody>
</table>
<p>So <code>https://devbox-ollama.ssh.test/api/tags</code> lists the models on the workstation, and <code>https://api.local.test</code> is the API of the app I&rsquo;m working on, running on my own machine.</p>
<p>Subdomains of <code>.test</code>, not new top-level names like <code>.ssh</code> or <code>.fly</code>, and not <code>.local</code>. RFC 6761 reserves <code>.test</code> for exactly this, and <code>.local</code> belongs to mDNS; take it over and Bonjour discovery stops working on your Mac. dnsmasq already wildcards the whole of <code>*.test</code>, so the extra label costs nothing: one <code>/etc/resolver/test</code> file pointing at <code>127.0.0.1</code> and no further DNS changes, ever.</p>

<h3 class="relative group">HTTPS on names that don&rsquo;t exist
    <div id="https-on-names-that-dont-exist" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#https-on-names-that-dont-exist" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Every one of those names is served over HTTPS with a certificate the browser trusts, which sounds impossible for a hostname that exists only on my laptop. Caddy does it with its own internal certificate authority. <code>tls internal</code> on a site tells Caddy to issue that site&rsquo;s certificate from a local CA instead of asking Let&rsquo;s Encrypt, and <code>caddy trust</code>, run once by the installer, puts the CA&rsquo;s root into the macOS System keychain (Chrome and Safari read it) and into Firefox&rsquo;s own store. From then on <code>https://anything.test</code> opens with no certificate warning, plain <code>http://</code> redirects to it, and nothing leaves the machine.</p>
<p>It isn&rsquo;t just cosmetic. A lot of the web platform now refuses to work on plain HTTP: secure cookies, service workers, the clipboard API, anything with <code>SameSite=None</code>. Testing an Expo web build on <code>http://localhost:8081</code> hides the bugs that show up on the real HTTPS domain. Testing on <code>https://app.local.test</code> surfaces them on my laptop instead.</p>
<p>A site block, which is the same for every service apart from the port:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="gh">devbox-ollama.ssh.test</span>, <span class="gh">devbox-ollama.test</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="k">tls</span> <span class="no">internal</span>
</span></span><span class="line"><span class="cl">	<span class="k">import</span> cors
</span></span><span class="line"><span class="cl">	<span class="k">reverse_proxy</span> <span class="n">127.0.0.1</span><span class="p">:</span><span class="mi">11435</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">		<span class="k">import</span> proxyhdr
</span></span><span class="line"><span class="cl">		<span class="k">transport</span> <span class="s">http</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">			<span class="k">keepalive</span> <span class="no">off</span>
</span></span><span class="line"><span class="cl">			<span class="k">dial_timeout</span> <span class="mi">5s</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="p">}</span></span></span></code></pre></div></div>
<p>The two snippets carry the fiddly parts. <code>cors</code> reflects the caller&rsquo;s <code>Origin</code> back rather than sending <code>*</code>, because <code>*</code> isn&rsquo;t allowed once credentials are involved, and it answers <code>OPTIONS</code> preflights itself with a 204 so the upstream never has to. <code>proxyhdr</code> rewrites the <code>Host</code> header to the dial address. Picky backends, router admin pages and Chrome&rsquo;s remote-debugging endpoint among them, validate <code>Host</code>, and would otherwise reject <code>devbox-ollama.ssh.test</code> as a name they&rsquo;ve never heard of. Ollama&rsquo;s own origin check accepts loopback on any port, so rewriting <code>Host</code> to <code>127.0.0.1:11435</code> gets it through.</p>
<p><code>keepalive off</code> is the one I&rsquo;d have got wrong. The upstreams are SSH forwards that autossh tears down and rebuilds whenever the network changes. With keep-alive on, Caddy holds a pooled connection to a tunnel that has since died, and it returns baffling 404s until it&rsquo;s reloaded. Off, every request dials fresh, which on loopback costs nothing measurable.</p>
<p>Each service also answers on its old flat name (<code>devbox-ollama.test</code>), so bookmarks from before the suffix scheme still work.</p>

<h3 class="relative group">The menu bar
    <div id="the-menu-bar" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-menu-bar" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>With a dozen forwards held open by launchd, the question I kept asking was &ldquo;is it up?&rdquo;, and the answer lived in three places: <code>launchctl list</code>, <code>lsof</code> and a <code>curl</code>. So a <a href="https://github.com/swiftbar/SwiftBar"  target="_blank" rel="noreferrer">SwiftBar</a> plugin puts it in one. The menu bar shows a 🚇; the menu underneath lists every hostname with a dot:</p>
<ul>
<li>🟢 the service answered a request through the tunnel</li>
<li>🟡 the tunnel is open but the service behind it is silent</li>
<li>🔴 nothing is listening on the local port, so the tunnel itself is down</li>
</ul>
<p>The yellow state is the useful one. A green/red check can&rsquo;t tell &ldquo;the SSH forward died&rdquo; from &ldquo;Ollama crashed on the far end&rdquo;, and those have entirely different fixes. Under each service is a submenu with Open, Kick tunnel, Restart Caddy and Tail log. Clicking the name opens it in the browser, and the actions sit on a separate grey <code>port → remote · tunnel</code> line below it, because in SwiftBar a menu item with children swallows its own click to expand them, so a combined row looks like a link and never opens one.</p>
<p>The plugin doesn&rsquo;t have its own list of services. It parses the live <code>Caddyfile</code> and <code>~/.ssh/config</code> on each run, so adding a service to those two files is also adding it to the menu.</p>
<p>The first version refreshed every ten seconds and shelled out to <code>lsof</code> and <code>launchctl</code> once <em>per service</em>, about seventy processes a run. macOS promptly listed SwiftBar under &ldquo;apps using significant energy&rdquo;. It now takes one <code>lsof</code> snapshot and one <code>launchctl</code> snapshot up front, answers every lookup from those strings, probes the remotes concurrently, and runs every five minutes: about 0.4 seconds of CPU a run, or 0.1% of a core. It deliberately doesn&rsquo;t refresh when the menu opens, because the Fly forwards take about 1.5 seconds each to answer over WireGuard and every click would hang on them. A <code>checked 14:05</code> line under the count says how stale the picture is, and a Refresh item forces a new one.</p>
<p>It had one hole, and it was the one that mattered. devbox has no public name. <code>tunnel-devbox</code> dialled a <code>192.168.x</code> address, so it only worked while the laptop was on the office LAN. Away from the office the menu bar went red and stayed red, and everything I&rsquo;d moved onto the workstation to save the laptop was out of reach at exactly the times I wasn&rsquo;t sitting next to it.</p>

<h2 class="relative group">Two tunnels, pointing the right way
    <div id="two-tunnels-pointing-the-right-way" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#two-tunnels-pointing-the-right-way" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The fix has three parts, and the first two are Cloudflare Tunnels.</p>
<p>A Cloudflare Tunnel is <code>cloudflared</code> holding an outbound connection to Cloudflare&rsquo;s edge. Cloudflare hands requests back down that connection, so a machine behind any amount of NAT gets reachable without anything listening on a router. It follows a laptop onto hotel wifi and it survives CGNAT.</p>
<p>I run two of them, one per machine, and the reason is worth a sentence. The obvious shortcut was a single tunnel on the laptop with one more ingress rule pointing at devbox&rsquo;s SSH port on the office LAN. That needs nothing installed on devbox at all. It also routes through the laptop, and the laptop is the machine that leaves the office. It would have broken in exactly the case it existed to fix. devbox stays put, so devbox holds its own connection to the edge.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># devbox/config.yml (run on devbox as a systemd service)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">auto       </span><span class="w"> </span><span class="c"># prefer QUIC, fall back to HTTP/2 over TCP 443</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ha-connections</span><span class="p">:</span><span class="w"> </span><span class="m">4</span><span class="w">     </span><span class="c"># mains-powered, its job is to stay reachable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">warp-routing</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">connectTimeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><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">ssh-devbox.example.com</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">tcp://127.0.0.1:22</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><code>tcp://</code> rather than <code>ssh://</code> is an easy one to get wrong. <code>ssh://</code> selects Cloudflare&rsquo;s browser-rendered terminal, which wraps the stream for a web page; a normal <code>ssh</code> client fed through it gets the banner and then dies at key exchange. <code>tcp://</code> is the plain byte pipe <code>cloudflared access ssh</code> expects, and as a bonus it carries <code>scp</code>, <code>rsync</code>, git over SSH and port forwards.</p>
<p>That last part is what closed the hole in Automatic Tunnel. The SSH config now picks a route per connection:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-sshconfig" data-lang="sshconfig">Match originalhost devbox,tunnel-devbox !exec &#34;nc -z -G 1 &lt;office-lan-ip&gt; 22 &gt;/dev/null 2&gt;&amp;1&#34;
    ProxyCommand cloudflared access ssh --hostname ssh-devbox.example.com ...</code></pre></div>
<p>On the office LAN the probe succeeds and SSH goes direct, one hop. Anywhere else it fails and SSH goes through Cloudflare, authenticated with an Access service token so there&rsquo;s no browser login in the way. It&rsquo;s <code>Match originalhost</code>, not <code>Match host</code>: <code>host</code> matches after <code>HostName</code> has been substituted, so by then the name is already an IP and the block never fires. The upshot is that <code>devbox-ollama.ssh.test</code> works in the office, at home and on a train, and the menu bar dot stays green.</p>
<p>The laptop&rsquo;s tunnel goes the other direction. It gives dev servers on the Mac real public HTTPS hostnames: for each of the apps I&rsquo;m working on, the Django API, its WebSocket app and the Expo web build. Every one of them sits behind Cloudflare Access, which I&rsquo;ll come back to, because it&rsquo;s the part that makes this safe rather than reckless.</p>
<p>The menu bar learned about both tunnels too, and it couldn&rsquo;t do it by probing. An inbound tunnel has no local port to check, one of the two connectors doesn&rsquo;t even run on the laptop, and the obvious test, curling <code>ssh-devbox.example.com</code>, gets an answer from Cloudflare&rsquo;s edge whether or not a connector is attached, so it would read green on a dead tunnel. launchd is no better: <code>cloudflared</code> sits there &ldquo;loaded, exit 0&rdquo; with every edge connection dropped. So that section of the menu asks the Cloudflare API what it believes about each tunnel, since that&rsquo;s what decides whether an <code>ssh</code> lands, and each row gets Tail log and Restart connector actions that run over SSH on the machine that owns it. If the API can&rsquo;t be reached within four seconds the section is left out entirely rather than drawn red. The menu is read at a glance, and a false alarm there costs more than a missing line.</p>

<h2 class="relative group">The third part: WARP makes it a network
    <div id="the-third-part-warp-makes-it-a-network" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-third-part-warp-makes-it-a-network" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Tunnels with hostnames are great for anything that speaks HTTPS, and for SSH from a machine that can run <code>cloudflared</code>. A phone can&rsquo;t. And a tunnel hostname isn&rsquo;t a TCP endpoint: Cloudflare&rsquo;s edge only terminates HTTPS and WebSocket on it, so port 22 at <code>ssh.example.com</code> is closed and no amount of fiddling with Access will open it.</p>
<p>That&rsquo;s the job for WARP. The Cloudflare One Agent (the enterprise WARP client, not the consumer &ldquo;1.1.1.1&rdquo; app, which can&rsquo;t join an organisation) is enrolled on the laptop and the phone in my Zero Trust organisation. Each tunnel advertises private routes, and a device running WARP can then open a plain socket to those addresses and land on the right machine. It stops being &ldquo;reach this service by name&rdquo; and becomes &ldquo;these machines are on a network with me&rdquo;:</p>
<table>
	<thead>
			<tr>
					<th>Address</th>
					<th>Machine</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>198.18.0.1</code> / <code>fd00:198:18::1</code></td>
					<td>the MacBook Air</td>
			</tr>
			<tr>
					<td><code>198.18.0.2</code> / <code>fd00:198:18::2</code></td>
					<td>devbox</td>
			</tr>
			<tr>
					<td><code>198.18.0.3</code> / <code>fd00:198:18::3</code></td>
					<td>a second Mac</td>
			</tr>
	</tbody>
</table>
<p>Those addresses are loopback aliases on each machine, created at boot, so they owe nothing to DHCP or whatever router I&rsquo;m behind. <code>198.18.0.2</code> is devbox in the office, from home, and from a phone on 5G. The Mac&rsquo;s real LAN address changes every time I move; this one never does.</p>
<p>The split tunnel is in <strong>include mode</strong> with only those routes in it (plus the Cloudflare ranges WARP needs for itself). That&rsquo;s what makes the client liveable. The default is exclude mode, where WARP carries everything except a list of private ranges, so every byte the phone sends detours through Cloudflare. That has a real battery and latency cost, enough that I&rsquo;d have ended up turning it on only when I needed it, which defeats the point. In include mode, traffic to my three machines goes through WARP and everything else goes out the normal path untouched. So the client just stays on. <code>warp-cli status</code> on the laptop says <code>Connected</code> all day and I don&rsquo;t think about it.</p>
<p>For transport I have it on MASQUE, which is HTTP/3 on UDP 443 with an HTTP/2 fallback, rather than WireGuard on UDP 2408. Some carriers and guest networks block 2408; almost nobody blocks 443. My organisation&rsquo;s policy also has post-quantum key agreement switched on for MASQUE, which I didn&rsquo;t go looking for but won&rsquo;t turn down.</p>

<h2 class="relative group">Why 198.18, and why there&rsquo;s an IPv6 address
    <div id="why-19818-and-why-theres-an-ipv6-address" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-19818-and-why-theres-an-ipv6-address" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The addresses look odd, and the first ones I picked were wrong in an instructive way.</p>
<p>My first attempt was <code>10.99.99.1</code>, because that&rsquo;s what you reach for. It fails in both split-tunnel modes. In exclude mode, WARP&rsquo;s default list excludes every RFC 1918 range plus <code>100.64.0.0/10</code>, so the client would have dropped traffic to it before it ever left the device. In include mode it would collide with any real <code>10.x</code> network I happen to join; cafés, co-working spaces and plenty of offices use exactly that range. <code>198.18.0.0/15</code> is the RFC 2544 benchmarking range. It&rsquo;s reserved, it&rsquo;s never routed on the public internet, it isn&rsquo;t in WARP&rsquo;s exclude list, and no ordinary network uses it. The IPv6 addresses are ULA (<code>fd00::/8</code>) for the same reason.</p>
<p>Why there&rsquo;s IPv6 at all is the more interesting story. Once the v4 address worked I tested it from the phone on wifi, it was fine, and I called it done. On 5G it didn&rsquo;t work. The Agent said <em>connected</em>, the tunnel was healthy, Access was fine; the wifi test proved all of that. The problem was that mobile networks are mostly IPv6-only now. The phone had no native IPv4 at all and was reaching the v4 literal through the carrier&rsquo;s 464XLAT translator, which sits <em>outside</em> the tunnel when the include list holds only a v4 prefix. The traffic went out to the public internet, where <code>198.18.0.0/15</code> doesn&rsquo;t route, and was dropped without a sound. No error on the phone, nothing in the connector&rsquo;s log.</p>
<p>The fix is to give every machine a second address the phone can reach natively over IPv6, and add it to the routes and to the include list. Now the <code>fd00:198:18::</code> addresses are what my SSH client on the phone actually uses on mobile data.</p>

<h2 class="relative group">Offloading: the laptop as a terminal
    <div id="offloading-the-laptop-as-a-terminal" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#offloading-the-laptop-as-a-terminal" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This is the part the whole setup exists for.</p>
<p><strong>Models and fine-tuning.</strong> Ollama runs on devbox and binds to <code>127.0.0.1:11434</code>. It&rsquo;s invisible to the rest of the office LAN, let alone the internet. On the laptop it appears at <code>https://devbox-ollama.ssh.test</code>, or as a bare port for tools that want one:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">OLLAMA_HOST</span><span class="o">=</span>127.0.0.1:11435 ollama list</span></span></code></pre></div></div>
<p>11435, not 11434, on purpose: 11434 is Ollama&rsquo;s own default, and if the laptop ever runs a local Ollama too, the two would race for the port and I&rsquo;d silently get devbox&rsquo;s answers from the &ldquo;local&rdquo; one depending on which started first.</p>
<p>The practical effect is that when I&rsquo;m building an AI feature, the model it calls is a workstation GPU rather than the laptop&rsquo;s, from wherever I am. Evaluation runs, embedding a pile of documents, a fine-tune in Unsloth Studio: they run on devbox, and the laptop&rsquo;s fan stays off. When I&rsquo;m prototyping an agent, it can point at a local model on the workstation and burn through tool-calling iterations without an API bill, and switch to a hosted model once the loop is right.</p>
<p><strong>Long-running agents.</strong> An agent session that runs for an hour shouldn&rsquo;t depend on my laptop lid staying open. Run it on devbox instead and the laptop is just a window onto it: close the lid, and later <code>ssh devbox</code> from wherever I am, or from the phone, and it&rsquo;s still there. Before the tunnel this only worked in the office, which is exactly where I didn&rsquo;t need it.</p>
<p><strong>Builds and the cluster.</strong> k3s on devbox has its own registry, so images can be built and pushed on the workstation, next to where they run, rather than uploaded over hotel wifi from a laptop. The Headlamp dashboard, the Traefik dashboard and the registry UI are all <code>*.ssh.test</code> hostnames on the laptop.</p>
<p><strong>The mobile loop.</strong> The phone is on the same private network as the laptop, and the laptop&rsquo;s dev servers also have public hostnames behind Access. So I can take a native Expo build out of the building, on 5G, and it talks to the Django API running on my desk. Or it talks to the one on devbox, if that&rsquo;s where I left it. The feedback loop on a real device over a real mobile network used to mean deploying to a shared staging server. Now it means saving a file.</p>
<p>There&rsquo;s one trap here worth knowing before you debug it as an app bug. Access protects the WebSocket hostname too, and a native WebSocket client isn&rsquo;t a browser. It carries no <code>CF_Authorization</code> cookie, so the Expo build&rsquo;s connection to the Channels app is rejected at the edge with a 403 that looks nothing like a Channels error. Browser tabs are fine because they already hold the cookie from logging in. For anything that isn&rsquo;t a browser (the native app, a script, an agent calling a dev API) the answer is an Access <strong>service token</strong>, sent as two headers:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">CF-Access-Client-Id:     &lt;client_id&gt;
</span></span><span class="line"><span class="cl">CF-Access-Client-Secret: &lt;client_secret&gt;</span></span></code></pre></div></div>
<p>The token grants nothing on its own. Access only honours it on apps whose policy names it, so its reach is exactly the list of dev hostnames and nothing wider. I file its policy under the <code>non_identity</code> decision rather than <code>allow</code>, which keeps it visible in the audit log as a bearer secret and not a person who logged in.</p>

<h2 class="relative group">The stats the internet never sees
    <div id="the-stats-the-internet-never-sees" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-stats-the-internet-never-sees" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The other half of what I wanted was visibility. Most of what tells you how a system is behaving is, correctly, not public. On devbox that&rsquo;s:</p>
<ul>
<li>the Traefik dashboard, and Jaeger with Traefik&rsquo;s traces in it</li>
<li>Headlamp for the k3s cluster</li>
<li>the container registry&rsquo;s UI (read-only)</li>
<li>Ollama&rsquo;s model list and what&rsquo;s loaded right now</li>
<li><code>docker info</code> and container stats</li>
</ul>
<p>None of those have a public hostname, and none ever will. They&rsquo;re bound to loopback or to k3s service IPs, reached over an SSH forward that itself rides the tunnel. A monitoring app on my phone reads <code>docker info --format '{{json .}}'</code> over SSH across WARP, so I can see from anywhere whether my containers are up or something has eaten all the memory.</p>
<p>That app is how I found a small bug worth mentioning. The tunnel sshd on the Mac was at <code>LogLevel DEBUG2</code> from when I was debugging it. With <code>-e</code>, sshd logs to stderr, and for a session that stderr <em>is</em> the SSH channel&rsquo;s stderr. Every command&rsquo;s output came back with <code>debug2: do_setup_env: set TMPDIR</code> appended, and the phone app, which merges the two streams, got valid JSON followed by a log line, failed to parse it, and showed the whole thing as an error. <code>LogLevel INFO</code> fixed it. Put log levels back after debugging.</p>
<p>Just as deliberate is what I <em>don&rsquo;t</em> put on this network. The old autossh tunnels also reach a few routers on other people&rsquo;s networks that I help look after. Those get no public hostname and no WARP route. A router admin page on the internet is a liability even behind a login, and it isn&rsquo;t my network to widen.</p>

<h2 class="relative group">Access is not optional
    <div id="access-is-not-optional" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#access-is-not-optional" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>A Cloudflare Tunnel hostname is on the public internet the moment its DNS record exists. There&rsquo;s no firewall in front of it; that&rsquo;s the whole point of not opening a port. The protection has to come from Cloudflare&rsquo;s edge, and that&rsquo;s what Access does. It refuses to forward a request at all until the visitor has proved who they are, so an unauthenticated scanner never reaches the laptop. That matters, because these are dev servers: a Django <code>DEBUG</code> traceback with settings in it, an open Metro bundler. Nothing behind these hostnames was built to face the internet.</p>
<p>The policy allows my own email addresses and nothing else, with a one-time PIN by email and GitHub SSO as the two ways to prove it. Sessions last eight hours, because the realistic threat is a laptop left open in a café with a day-long cookie, not someone brute-forcing a PIN.</p>
<p>SSH is authenticated twice, by two systems that don&rsquo;t know about each other. Access decides whether a TCP stream may reach sshd at all; sshd still demands my key. Losing either doesn&rsquo;t open the machine. The Mac&rsquo;s tunnel sshd also trusts Cloudflare&rsquo;s SSH CA, so Access can log me in with a short-lived certificate minted from my SSO session, while <code>authorized_keys</code> still works alongside it, so a broken Access config can&rsquo;t lock me out of my own laptop.</p>
<p>The piece I&rsquo;m happiest with is small. Exposure happens when three things line up: a DNS record, a running connector and an active zone. They become true at different times, in an order I don&rsquo;t control; the zone flips to active on its own, some time after the nameservers change, quite possibly while the Mac is asleep. So the launch agent doesn&rsquo;t run <code>cloudflared</code> directly. It runs a preflight that reads the hostnames out of the tunnel&rsquo;s own config, asks the Access API which ones are covered, and refuses to start if any aren&rsquo;t:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">HOSTNAMES</span><span class="o">=</span><span class="k">$(</span>grep -E <span class="s1">&#39;^\s*-\s*hostname:&#39;</span> <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> <span class="p">|</span> sed -E <span class="s1">&#39;s/.*hostname:\s*//&#39;</span> <span class="p">|</span> sort -u<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROTECTED</span><span class="o">=</span><span class="k">$(</span>curl -fsS -H <span class="s2">&#34;Authorization: Bearer </span><span class="nv">$CLOUDFLARE_API_TOKEN</span><span class="s2">&#34;</span> <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">$CLOUDFLARE_ACCOUNT_ID</span><span class="s2">/access/apps?per_page=100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> python3 -c <span class="s1">&#39;import sys,json; print(&#34;\n&#34;.join(a.get(&#34;domain&#34;,&#34;&#34;) for a in json.load(sys.stdin)[&#34;result&#34;]))&#39;</span><span class="k">)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;could not reach the Access API. refusing (fail closed).&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> h in <span class="nv">$HOSTNAMES</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    grep -qxF <span class="s2">&#34;</span><span class="nv">$h</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$PROTECTED</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;NOT starting: </span><span class="nv">$h</span><span class="s2"> has no Access app&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> cloudflared tunnel --config <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> run</span></span></code></pre></div></div>
<p>The launch agent has <code>KeepAlive</code>, so a refusal retries by itself every 30 seconds and the tunnel comes up on its own once the Access apps exist. Failing closed costs an outage. Failing open costs an exposure. I know which of those I&rsquo;d rather explain.</p>

<h2 class="relative group">Four things that bit me
    <div id="four-things-that-bit-me" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#four-things-that-bit-me" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Everything above works now. Getting there, three of the four had the same pattern: the thing that was broken produced no error anywhere, and the parts I could see all reported healthy.</p>
<p><strong>The route and the include list are two different lists.</strong> A tunnel route makes Cloudflare <em>willing</em> to carry an address to the right connector. The WARP include list is what makes the client <em>send</em> it in the first place. devbox was unreachable from the phone because its routes existed but the include list still only had the Mac&rsquo;s addresses. The phone dropped the packets locally, the connector never saw a flow, and every dashboard was green. Every new machine means adding its addresses to both.</p>
<p><strong>IPv4 literals on IPv6-only mobile networks.</strong> Covered above: works on wifi, fails on 5G, no error. If a WARP route works on wifi and not on mobile data, and the Agent shows connected, it&rsquo;s routing, not the tunnel.</p>
<p><strong>macOS sshd behind cloudflared.</strong> macOS starts its sshd with launchd socket activation, one short-lived <code>sshd -i</code> per connection. Behind cloudflared, the banner reaches the client and then the stream is torn down before the client&rsquo;s identification string gets back, so every login dies at <code>kex_exchange_identification</code>. I confirmed it from both ends and reproduced it with a plain TCP relay, which ruled out Cloudflare. The fix is a second, standalone <code>sshd -D</code> on <code>127.0.0.1:2222</code> and the WARP loopback addresses, running as my user, that exists only to be the tunnel&rsquo;s origin. Remote Login in System Settings is untouched. devbox, being Linux with a normal long-lived sshd, never had the problem.</p>
<p><strong>Config fields that stop existing.</strong> Older examples have <code>warp-routing: enabled: true</code> in the tunnel config. cloudflared 2026.8 rejects that field outright. WARP routing is on whenever the block exists and a route points at the tunnel. That one at least produced an error, which after the other three felt almost generous.</p>

<h2 class="relative group">What it changed
    <div id="what-it-changed" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-it-changed" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The honest summary is that I stopped choosing where to work based on where the hardware is. The laptop is a good keyboard and a good screen with a long battery life, and it doesn&rsquo;t need to be more than that. The workstation does the heavy lifting from wherever I am. The phone is a real test device on a real network rather than something tethered to office wifi. And the parts of the system I use to see what it&rsquo;s doing are one click away for me and don&rsquo;t exist for anyone else.</p>
<p>At this size it fits in Cloudflare&rsquo;s free Zero Trust plan. What it cost me was time and a README I&rsquo;m glad I wrote, because every one of the failures above would have been a fresh mystery the second time.</p>
<p>If you&rsquo;re thinking about something similar, these are the questions I&rsquo;d ask of your setup:</p>
<ol>
<li>What runs on your laptop only because the laptop is the one machine you can always reach?</li>
<li>Can your phone reach your dev server on mobile data, not just on the office wifi? Test it on 5G, not wifi.</li>
<li>Which of your dashboards are public because that was the easy way to see them from outside? Each one is a candidate for a private route instead.</li>
<li>If a tunnel hostname came up without its Access app tomorrow, what would stop it? If the answer is &ldquo;I&rsquo;d remember&rdquo;, write the preflight.</li>
<li>Are your private addresses in a range the networks you visit will never use?</li>
</ol>
]]></content:encoded>
    </item>
  </channel>
</rss>
