<?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>Cloudflared · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/cloudflared/</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>Wed, 11 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/cloudflared/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Self-Host From Your Laptop for Free With Cloudflare Tunnel</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Cloudflare Tunnel</category>
      <category>cloudflared</category>
      <category>Self-Hosting</category>
      <category>Expose Localhost</category>
      <category>Docker Compose</category>
      <category>Django</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Security</category>
      <category>Dev Costs</category>
      <description>Expose localhost to the internet for free with Cloudflare Tunnel: a real HTTPS hostname, no open ports, and an ingress allowlist, set up with Docker Compose.</description>
      <content:encoded><![CDATA[<p>Every idea I&rsquo;ve had starts the same way: it runs on my laptop, it works, and I want someone else to see it. For a while the next step was always a cloud bill. A machine, a database, a bucket, a domain pointed at it, and a monthly charge for something three people will look at. For a side project, or the first month of a real one, that&rsquo;s the wrong order: you pay for production before you know whether there will be one.</p>
<p>This post is the other order. The service keeps running on the machine it was built on, and a Cloudflare Tunnel gives it a public HTTPS hostname. Nothing on the laptop listens to the internet: no port forwarding, no router settings, no exposed IP address. The machine dials out to Cloudflare, and Cloudflare sends requests back down that connection. The tunnel is free, and the TLS certificate, DDoS protection and caching come with the Cloudflare zone you probably already have.</p>
<p>The example is real. One of <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/" >my products</a> runs on Fly.io, and its mobile app gets over-the-air JavaScript updates from the backend (self-hosted Expo Updates). I wanted a second copy of that update server running on my Mac for development builds, with production left exactly as it was. The outputs below are from that afternoon, including the part where it didn&rsquo;t work.</p>

<h2 class="relative group">What a Cloudflare Tunnel actually is
    <div id="what-a-cloudflare-tunnel-actually-is" 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-a-cloudflare-tunnel-actually-is" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>A normal server waits for connections. To reach a laptop from the internet, you&rsquo;d have to open a port on your router, forward it to the laptop, and hope your ISP gives you a public IPv4 address (in a lot of places, mine included, it doesn&rsquo;t). Then everything listening on that port is reachable by every scanner on the internet, all day.</p>
<p>A tunnel turns that around. <code>cloudflared</code> runs next to your app and opens outbound connections to Cloudflare&rsquo;s edge, four of them to two nearby data centres. A DNS record in your zone points a hostname at the tunnel. When a request arrives for that hostname, Cloudflare terminates TLS and forwards the request down one of those connections. <code>cloudflared</code> then hands it to whichever local service your config names.</p>
<figure class="diagram">
  <div role="img" aria-label="A request goes over HTTPS to Cloudflare, through its rules, to the tunnel endpoint, and down an outbound connection that cloudflared opened from your machine; cloudflared sends it to the API, the edge, or a 404 by hostname and path"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 520" aria-hidden="true" focusable="false">
<defs><marker id="tu-ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="d-head" d="M0,0 L10,5 L0,10 z"/></marker></defs>
<text class="d-title" x="160" y="60" text-anchor="middle" font-size="30">Internet</text>
<text class="d-title" x="560" y="60" text-anchor="middle" font-size="30">Cloudflare</text>
<text class="d-title" x="1020" y="60" text-anchor="middle" font-size="30">Your machine</text>
<rect class="d-box" x="40" y="200" width="240" height="100" rx="14"/>
<text class="d-label" x="160" y="245" text-anchor="middle" font-size="24">Phone / browser</text>
<text class="d-sub" x="160" y="278" text-anchor="middle" font-size="18">ota.example.com</text>
<rect class="d-panel" x="400" y="110" width="320" height="300" rx="30"/>
<rect class="d-box" x="430" y="140" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="188" text-anchor="middle" font-size="22">TLS, redirect rules, WAF</text>
<rect class="d-box" x="430" y="290" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="338" text-anchor="middle" font-size="22">Tunnel endpoint</text>
<rect class="d-group" x="820" y="110" width="400" height="360" rx="30"/>
<rect class="d-box" x="850" y="290" width="160" height="80" rx="14"/>
<text class="d-label" x="930" y="338" text-anchor="middle" font-size="22">cloudflared</text>
<rect class="d-box" x="1050" y="160" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="202" text-anchor="middle" font-size="20">api:8000</text>
<rect class="d-box" x="1050" y="290" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="332" text-anchor="middle" font-size="20">edge:8081</text>
<rect class="d-box" x="1050" y="380" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="422" text-anchor="middle" font-size="20">404</text>
<path class="d-line" d="M280,250 L428,190" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="350" y="200" text-anchor="middle" font-size="18">HTTPS</text>
<path class="d-line" d="M560,220 L560,288" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M848,330 L692,330" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="770" y="315" text-anchor="middle" font-size="16">outbound QUIC</text>
<text class="d-sub" x="770" y="360" text-anchor="middle" font-size="16">(no open port)</text>
<path class="d-line" d="M1010,315 L1048,200" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,330 L1048,325" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,345 L1048,410" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="1020" y="500" text-anchor="middle" font-size="18">ingress rules: hostname + path allowlist</text>
</svg>
</div><figcaption>The machine only ever connects out. Cloudflare&#39;s zone rules run before the tunnel; the ingress rules decide what is reachable.</figcaption>
</figure>

<p>For security, the important part is that your router has no inbound rule and your IP address never appears in DNS. Someone scanning your home connection finds nothing, because nothing on it is listening.</p>

<h2 class="relative group">Setting up cloudflared, start to finish
    <div id="setting-up-cloudflared-start-to-finish" 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="#setting-up-cloudflared-start-to-finish" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>You need a domain on Cloudflare (the free plan is enough) and <code>cloudflared</code> installed (<code>brew install cloudflared</code> on a Mac).</p>
<p><strong>1. Log in once.</strong> This opens a browser, you pick the zone, and it saves a certificate that lets this machine create tunnels and DNS records in that zone:</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">cloudflared tunnel login</span></span></code></pre></div></div>
<p><strong>2. Create the tunnel.</strong> I keep everything for this project in its own folder, because the credentials file is the tunnel&rsquo;s identity: anyone who has it can serve traffic for your hostnames.</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">mkdir -p ~/.cloudflared/myapp <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/.cloudflared/myapp
</span></span><span class="line"><span class="cl">cloudflared tunnel create --credentials-file ./creds.json myapp-local
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> creds.json</span></span></code></pre></div></div>
<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">Tunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
</span></span><span class="line"><span class="cl">Created tunnel myapp-local with id 1ade030c-…</span></span></code></pre></div></div>
<p><strong>3. Point hostnames at it.</strong> One command per hostname creates a proxied CNAME to <code>&lt;tunnel-id&gt;.cfargotunnel.com</code>:</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">cloudflared tunnel route dns myapp-local ota.example.com
</span></span><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local checkin-local.example.com</span></span></code></pre></div></div>
<p><strong>4. Write the ingress rules.</strong> This is the part that matters for security, so it gets its own section below.</p>
<p><strong>5. Run it.</strong> My app already runs under Docker Compose, so <code>cloudflared</code> is one more container on the same network. It reaches the app by service name, and the app doesn&rsquo;t need a port published on the host at all:</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"># compose.tunnel.yml, used alongside the project&#39;s docker-compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cloudflared</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/cloudflared:2026.10.7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel --config /etc/cloudflared/config.yml run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><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="l">${HOME}/.cloudflared/myapp:/etc/cloudflared:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">api, edge]</span></span></span></code></pre></div></div>
<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">docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflared</span></span></code></pre></div></div>
<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">INF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quic</span></span></code></pre></div></div>
<p>Four connections to Seoul, and the laptop is on the internet, from the outside in. <code>restart: unless-stopped</code> brings it back after a reboot as soon as Docker starts.</p>

<h2 class="relative group">Expose paths, not servers
    <div id="expose-paths-not-servers" 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="#expose-paths-not-servers" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This is the mistake I nearly made, and I&rsquo;d guess it&rsquo;s the most common one. The easy config is a single line: <code>hostname → http://localhost:8000</code>. That publishes <strong>the whole development server</strong>.</p>
<p>Development servers aren&rsquo;t built for that. Mine runs with <code>DEBUG=True</code>, which shows full tracebacks and settings on any error. It also runs with authentication switched off (<code>AUTH_MODE=disabled</code>), so that any bearer token logs you in as a test user. That&rsquo;s exactly right on <code>localhost</code> and a disaster on a public hostname. Yours probably has something similar: an admin with a default password, a seeding endpoint, a debug toolbar, or an API that trusts its caller because only you ever call it.</p>
<p>So the tunnel config is an allowlist. <code>cloudflared</code> checks each request against the ingress rules in order, by hostname and by a regular expression on the path. The first match wins, and the last rule catches everything else:</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="nt">tunnel</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-local</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">credentials-file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/cloudflared/creds.json</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="c"># OTA: the two read-only endpoints the phone calls, and the files they link.</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">ota.example.com</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">^/api/expo-updates/(manifest|status)/$</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://api:8000</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">ota.example.com</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">^/media/</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://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Device check-ins, authenticated by each device&#39;s own key.</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">checkin-local.example.com</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">^/c/$</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://edge:8081</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Everything else, including /admin/ and the publish endpoint: 404 at the edge.</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>The publishing endpoint lives on the same server under the same prefix (<code>/api/expo-updates/import-update/</code>), and it isn&rsquo;t in the list. Neither is the admin, or the rest of the API. Those requests never leave Cloudflare.</p>
<p>You don&rsquo;t have to trust your regexes; <code>cloudflared</code> will tell you which rule a URL matches:</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">cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/</span></span></code></pre></div></div>
<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">https://ota.example.com/api/expo-updates/manifest/        service: http://api:8000
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/import-update/   service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                            service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/media/expo-updates/x.js           service: http://api:8000
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                      service: http://edge:8081
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/health                  service: http_status:404</span></span></code></pre></div></div>
<p>I&rsquo;d put that check in a script and run it every time the config changes. It takes seconds, and a regex that matches more than you meant is the kind of mistake you only find out about from someone else.</p>
<p>Two more rules of thumb:</p>
<ul>
<li><strong>What you expose should authenticate itself.</strong> The OTA endpoints serve public, read-only data. Device check-ins are signed with per-device keys. Neither one depends on the network to keep it safe. If you want to show someone a page that has no login, put <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/"  target="_blank" rel="noreferrer">Cloudflare Access</a> in front of that hostname (free for up to 50 users) instead of building a login.</li>
<li><strong>Don&rsquo;t run the tunnel as a background part of your dev workflow.</strong> Run it on purpose, for the hostname you mean, and stop it when you&rsquo;re done. <code>docker compose stop cloudflared</code> takes everything off the internet in one command.</li>
</ul>

<h2 class="relative group">The redirect that ate my hostnames
    <div id="the-redirect-that-ate-my-hostnames" 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-redirect-that-ate-my-hostnames" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>With the tunnel connected and the rules verified, I tried it from outside:</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">https://ota.example.com/api/expo-updates/status/?…   301
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/   301
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                       301
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                 301</span></span></code></pre></div></div>
<p>Every path returned the same 301, including ones the tunnel should have answered with 404. That means the request never reached <code>cloudflared</code>. The <code>Location</code> header said <code>https://www.example.com/</code>, and the <code>server: cloudflare</code> header settled it: Cloudflare was answering on its own.</p>
<p>I&rsquo;d done this to myself. Months ago I added a zone-level redirect rule, &ldquo;any other subdomain → www&rdquo;, as <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >a sinkhole for scanners that walk invented subdomains</a>. Redirect rules run in Cloudflare&rsquo;s request pipeline before the request is sent to the origin, and a tunnel is just an origin. <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >The same rule caught my preview environments</a> before Workers could see them. The fix is the same as last time: one more exception in the rule&rsquo;s expression.</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">… and not http.host in {&#34;ota.example.com&#34; &#34;checkin-local.example.com&#34;}</span></span></code></pre></div></div>
<p>If your tunnel hostname answers with a redirect or an error page from Cloudflare instead of your app, check <strong>Rules → Redirect Rules</strong>, Page Rules and any WAF custom rules for the zone before you debug the tunnel. <code>cloudflared</code> logs every request it forwards, so an empty log means the request never got that far.</p>

<h2 class="relative group">What it can&rsquo;t do
    <div id="what-it-cant-do" 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-cant-do" aria-label="Anchor">#</a>
    </span>
    
</h2>
<ul>
<li><strong>UDP from the public internet.</strong> Public tunnel hostnames carry HTTP(S) (plus WebSockets and gRPC). My product&rsquo;s devices normally check in over CoAP on UDP, and that can&rsquo;t go through a tunnel; the devices on the local copy fall back to HTTPS check-ins, which cost them a few seconds on each one. If your idea is a game server or VoIP, a tunnel isn&rsquo;t the tool.</li>
<li><strong>Uptime.</strong> It&rsquo;s your laptop. Close the lid and the site is down; Cloudflare shows a 530 error page. That&rsquo;s fine for a demo, a beta with ten people, or a staging copy. It isn&rsquo;t fine for anything someone depends on.</li>
<li><strong>Things baked into a build.</strong> A mobile app&rsquo;s update URL is native config, so only builds made with the tunnel&rsquo;s URL use it. My store builds keep talking to production, and that&rsquo;s why this setup was safe to try.</li>
</ul>

<h2 class="relative group">The cost: is Cloudflare Tunnel free?
    <div id="the-cost-is-cloudflare-tunnel-free" 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-cost-is-cloudflare-tunnel-free" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>A Cloudflare zone on the free plan, the tunnel, its certificate, and the DNS records: $0. The machine was already running. The only real cost is that you&rsquo;re now the uptime team.</p>
<p>What it saves you is the phase between &ldquo;it works on my machine&rdquo; and &ldquo;it&rsquo;s worth paying to host&rdquo;. You can send a link to a tester, a designer or a first customer the same afternoon. If nobody cares, you&rsquo;ve lost nothing. If they do, moving to a real host is one change: point the CNAME somewhere else.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
