<?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:wc="https://wilhelm.codes/ns/feed/1.0"><channel><title>Wilhelm Codes · Cloudflare</title><link>https://wilhelm.codes/tags/cloudflare/</link><description>Slinger of nibbles, bits and bytes. Over 25 years of professional experience as a software engineer. Love making glowing rectangles go "beep-boop".</description><generator>Hugo</generator><language>en-US</language><managingEditor>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</managingEditor><webMaster>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</webMaster><lastBuildDate>Fri, 11 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://wilhelm.codes/tags/cloudflare/index.xml" rel="self" type="application/rss+xml"/><item><title>The Safest Login Page is the One I Never Published</title><link>https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/</link><pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/</guid><category>homelab</category><category>umami</category><category>cloudflare</category><category>analytics</category><wc:kind>post</wc:kind><description> In retrospect this really should&amp;rsquo;ve been a two-parter 😬. I finally got tired of not knowing whether anyone reads this thing, so I spent a Saturday afternoon standing up Umami in the homelab. The install was the easy part; the instructions are dead simple.
Then I got to the bit where you expose it to the internet. I went along with the advice everyone gives, stopped halfway and decided it wasn&amp;rsquo;t good enough for me. So, I did what I normally do and stubbornly wandered off to my own path.</description><content:encoded><![CDATA[<div class="callout callout-notice">
  In retrospect this really should&rsquo;ve been a two-parter 😬.
</div>

<p>I finally got tired of not knowing whether anyone reads this thing, so I spent a Saturday afternoon standing up <a href="https://umami.is">Umami</a> in the homelab. The install was the easy part; the instructions are dead simple.</p>
<p>Then I got to the bit where you expose it to the internet. I went along with the advice everyone gives, stopped halfway and decided it wasn&rsquo;t good enough for <em>me</em>. So, I did what I normally do and stubbornly wandered off to my own path.</p>
<p>Quick bit of context for anyone who hasn&rsquo;t gone down this particular hole. Umami is a self-hosted analytics platform: a small Node app with a PostgreSQL database to maintain persistence sans the cookies and third parties slurping up all the data. It&rsquo;s as simple as dropping one script tag on your site. It posts a little blob of JSON on each page view and you get a dashboard. My install runs in an LXC on a member node of my homelab&rsquo;s Proxmox cluster.</p>
<p>I landed on it for the &ldquo;normal&rdquo; reasons. Cloudflare&rsquo;s Analytics are free but limited. They don&rsquo;t give me precisely what I&rsquo;m looking for and they&rsquo;re a bit too slow for my liking. And, I&rsquo;m not even going to give Google a single thought; they have enough of my data already.</p>
<p>Which leaves the question: how does a script tag on a public website safely and <em>securely</em> reach a container in my house without punching a hole in my router?</p>
<h2 id="the-answer-everyone-gives-you">
  <a class="heading-link" href="#the-answer-everyone-gives-you">The answer everyone gives you.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The standard answer is a <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/">Cloudflare Tunnel</a>. A small daemon, <code>cloudflared</code>, runs next to your service and dials <em>out</em> to Cloudflare. No dicking around with port forwarding, firewall rules or opening up your router to the world. Traffic arrives at Cloudflare, goes down the pipe your daemon already opened and lands on your service. It&rsquo;s genuinely lovely and it costs nothing. And since I already use Cloudflare to <em>currently</em> host my static websites and domains it was just the pragmatic option.</p>
<p>I think it&rsquo;s worth noting there is a real trade off here. A tunnel means Cloudflare terminates your TLS. Since they decrypt at the edge, they could inspect whatever they like and they re-encrypt on the way down the pipe. For page view pings carrying a URL and a screen size, I genuinely do not care. For anything with secrets in the payload I would care enormously and so should you.</p>
<p>Anyway, Umami has an admin interface. An admin interface typically has a login page. So, most guides you&rsquo;ll find after searching around will tell you to put something like <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/">Cloudflare Access</a> in front of it. Access is an authentication layer at the edge. Basically, someone hits your hostname, Cloudflare intercepts, they sign in against an identity provider and only then does the request continue to your box.</p>
<p>I was about to wire it to my IDP when I actually stopped to read my own ingress config a bit more closely.</p>
<h2 id="tunnels-match-on-path">
  <a class="heading-link" href="#tunnels-match-on-path">Tunnels match on path.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The important little detail I almost skimmed past was that a tunnel&rsquo;s ingress rules do not just match on hostname, but on <em>path</em>.</p>
<p>I had written this, which is what everyone starts with:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="n">ingress</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    hostname</span> <span class="o">=</span> <span class="s2">&#34;analytics.nightcity.network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    service</span>  <span class="o">=</span> <span class="s2">&#34;http://umami:3000&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    service</span> <span class="o">=</span> <span class="s2">&#34;http_status:404&#34;</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>You write a single hostname and everything on it goes straight to the app while anything else gets a <code>404</code>. Ok.</p>
<p>But my analytics service only <em>needs</em> to expose <em>two things</em> to the public internet; the tracker script and the collect endpoint. Nobody on the internet needs to see <code>/login</code> or the dashboard, the settings, the user management or the API the dashboard talks to. So, why was I publishing everything and then buying into another service to place in front of it?</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="n">ingress</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    hostname</span> <span class="o">=</span> <span class="s2">&#34;analytics.nightcity.network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    path</span>     <span class="o">=</span> <span class="s2">&#34;^/bundle\\.js$&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    service</span>  <span class="o">=</span> <span class="s2">&#34;http://nginx:80&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    hostname</span> <span class="o">=</span> <span class="s2">&#34;analytics.nightcity.network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    path</span>     <span class="o">=</span> <span class="s2">&#34;^/api/v1/data$&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    service</span>  <span class="o">=</span> <span class="s2">&#34;http://nginx:80&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    service</span> <span class="o">=</span> <span class="s2">&#34;http_status:404&#34;</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>I only need precisely <em>two</em> paths out while everything else gets met with a lovely <code>404</code>.</p>
<p>My admin interface still exists, of course ( I am not typing SQL to read my own page views ). It sits on my home network behind my own reverse proxy, reachable from the couch or over my established VPN.</p>
<p>Drawn out, the whole arrangement is two seperate paths that happen to share a name:</p>
<figure class="diagram"><svg id="d-9c432e0a5e48" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 597.145px; background-color: transparent;" viewBox="0 0 597.14453125 635" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#d-9c432e0a5e48{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;fill:#32d74b;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#d-9c432e0a5e48 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#d-9c432e0a5e48 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#d-9c432e0a5e48 .error-icon{fill:#202225;}#d-9c432e0a5e48 .error-text{fill:#e3e3de;stroke:#e3e3de;}#d-9c432e0a5e48 .edge-thickness-normal{stroke-width:1px;}#d-9c432e0a5e48 .edge-thickness-thick{stroke-width:3.5px;}#d-9c432e0a5e48 .edge-pattern-solid{stroke-dasharray:0;}#d-9c432e0a5e48 .edge-thickness-invisible{stroke-width:0;fill:none;}#d-9c432e0a5e48 .edge-pattern-dashed{stroke-dasharray:3;}#d-9c432e0a5e48 .edge-pattern-dotted{stroke-dasharray:2;}#d-9c432e0a5e48 .marker{fill:#8b8b84;stroke:#8b8b84;}#d-9c432e0a5e48 .marker.cross{stroke:#8b8b84;}#d-9c432e0a5e48 svg{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;}#d-9c432e0a5e48 p{margin:0;}#d-9c432e0a5e48 .label{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;color:#32d74b;}#d-9c432e0a5e48 .cluster-label text{fill:#e3e3de;}#d-9c432e0a5e48 .cluster-label span{color:#e3e3de;}#d-9c432e0a5e48 .cluster-label span p{background-color:transparent;}#d-9c432e0a5e48 .label text,#d-9c432e0a5e48 span{fill:#32d74b;color:#32d74b;}#d-9c432e0a5e48 .node rect,#d-9c432e0a5e48 .node circle,#d-9c432e0a5e48 .node ellipse,#d-9c432e0a5e48 .node polygon,#d-9c432e0a5e48 .node path{fill:#1e1f22;stroke:#3c3f43;stroke-width:1px;}#d-9c432e0a5e48 .rough-node .label text,#d-9c432e0a5e48 .node .label text,#d-9c432e0a5e48 .image-shape .label,#d-9c432e0a5e48 .icon-shape .label{text-anchor:middle;}#d-9c432e0a5e48 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#d-9c432e0a5e48 .rough-node .label,#d-9c432e0a5e48 .node .label,#d-9c432e0a5e48 .image-shape .label,#d-9c432e0a5e48 .icon-shape .label{text-align:center;}#d-9c432e0a5e48 .node.clickable{cursor:pointer;}#d-9c432e0a5e48 .root .anchor path{fill:#8b8b84!important;stroke-width:0;stroke:#8b8b84;}#d-9c432e0a5e48 .arrowheadPath{fill:#dfddda;}#d-9c432e0a5e48 .edgePaths .path{stroke:#8b8b84;stroke-width:1px;}#d-9c432e0a5e48 .flowchart-link{stroke:#8b8b84;fill:none;}#d-9c432e0a5e48 .edgeLabel{background-color:#1e1f22;text-align:center;}#d-9c432e0a5e48 .edgeLabel p{background-color:#1e1f22;}#d-9c432e0a5e48 .edgeLabel rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-9c432e0a5e48 .labelBkg{background-color:rgba(30, 31, 34, 0.5);}#d-9c432e0a5e48 .cluster rect{fill:#202225;stroke:#2c2e31;stroke-width:1px;}#d-9c432e0a5e48 .cluster text{fill:#e3e3de;}#d-9c432e0a5e48 .cluster span{color:#e3e3de;}#d-9c432e0a5e48 .node .collapsed-indicator{fill:#2c2e31;stroke:none;opacity:0.6;}#d-9c432e0a5e48 .node .collapsed-separator{stroke:#2c2e31;stroke-width:0.75px;}#d-9c432e0a5e48 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;background:#202225;border:1px solid hsl(216, 0%, 23.5294117647%);border-radius:2px;pointer-events:none;z-index:100;}#d-9c432e0a5e48 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#32d74b;}#d-9c432e0a5e48 rect.text{fill:none;stroke-width:0;}#d-9c432e0a5e48 .icon-shape,#d-9c432e0a5e48 .image-shape{background-color:#1e1f22;text-align:center;}#d-9c432e0a5e48 .icon-shape p,#d-9c432e0a5e48 .image-shape p{background-color:#1e1f22;padding:2px;}#d-9c432e0a5e48 .icon-shape .label rect,#d-9c432e0a5e48 .image-shape .label rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-9c432e0a5e48 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#d-9c432e0a5e48 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#d-9c432e0a5e48 .node .neo-node{stroke:#3c3f43;}#d-9c432e0a5e48 [data-look="neo"].node rect,#d-9c432e0a5e48 [data-look="neo"].cluster rect,#d-9c432e0a5e48 [data-look="neo"].node polygon{stroke:url(#d-9c432e0a5e48-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 [data-look="neo"].swimlane.cluster rect{filter:none;}#d-9c432e0a5e48 [data-look="neo"].node path{stroke:url(#d-9c432e0a5e48-gradient);stroke-width:1px;}#d-9c432e0a5e48 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 [data-look="neo"].node .neo-line path{stroke:#3c3f43;filter:none;}#d-9c432e0a5e48 [data-look="neo"].node circle{stroke:url(#d-9c432e0a5e48-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 [data-look="neo"].node circle .state-start{fill:#000000;}#d-9c432e0a5e48 [data-look="neo"].icon-shape .icon{fill:url(#d-9c432e0a5e48-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#d-9c432e0a5e48-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker id="d-9c432e0a5e48_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"/></marker><g class="root"><g class="clusters"/><g class="edgePaths"><path d="M260.007,59L243.818,64.917C227.629,70.833,195.252,82.667,179.064,93.833C162.875,105,162.875,115.5,162.875,120.75L162.875,126" id="d-9c432e0a5e48-L_N_CD_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_N_CD_0" data-points="W3sieCI6MjYwLjAwNjY5MTg1NDUwODIsInkiOjU5fSx7IngiOjE2Mi44NzUsInkiOjk0LjV9LHsieCI6MTYyLjg3NSwieSI6MTMwfV0=" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M397.473,59L413.18,64.917C428.887,70.833,460.301,82.667,476.008,98.75C491.715,114.833,491.715,135.167,491.715,155.5C491.715,175.833,491.715,196.167,491.715,216.5C491.715,236.833,491.715,257.167,491.715,275.75C491.715,294.333,491.715,311.167,491.715,323.083C491.715,335,491.715,342,491.715,345.5L491.715,349" id="d-9c432e0a5e48-L_N_TD_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_N_TD_0" data-points="W3sieCI6Mzk3LjQ3MjUyODE3NjIyOTUsInkiOjU5fSx7IngiOjQ5MS43MTQ4NDM3NSwieSI6OTQuNX0seyJ4Ijo0OTEuNzE0ODQzNzUsInkiOjE1NS41fSx7IngiOjQ5MS43MTQ4NDM3NSwieSI6MjE2LjV9LHsieCI6NDkxLjcxNDg0Mzc1LCJ5IjoyNzcuNX0seyJ4Ijo0OTEuNzE0ODQzNzUsInkiOjMyOH0seyJ4Ijo0OTEuNzE0ODQzNzUsInkiOjM1M31d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M162.875,181L162.875,186.917C162.875,192.833,162.875,204.667,162.875,215.833C162.875,227,162.875,237.5,162.875,242.75L162.875,248" id="d-9c432e0a5e48-L_CD_E_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_CD_E_0" data-points="W3sieCI6MTYyLjg3NSwieSI6MTgxfSx7IngiOjE2Mi44NzUsInkiOjIxNi41fSx7IngiOjE2Mi44NzUsInkiOjI1Mn1d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M491.715,404L491.715,409.917C491.715,415.833,491.715,427.667,491.715,438.833C491.715,450,491.715,460.5,491.715,465.75L491.715,471" id="d-9c432e0a5e48-L_TD_P_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_TD_P_0" data-points="W3sieCI6NDkxLjcxNDg0Mzc1LCJ5Ijo0MDR9LHsieCI6NDkxLjcxNDg0Mzc1LCJ5Ijo0MzkuNX0seyJ4Ijo0OTEuNzE0ODQzNzUsInkiOjQ3NX1d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M162.875,303L162.875,307.167C162.875,311.333,162.875,319.667,162.875,327.333C162.875,335,162.875,342,162.875,345.5L162.875,349" id="d-9c432e0a5e48-L_E_C_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_E_C_0" data-points="W3sieCI6MTYyLjg3NSwieSI6MzAzfSx7IngiOjE2Mi44NzUsInkiOjMyOH0seyJ4IjoxNjIuODc1LCJ5IjozNTN9XQ==" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M115.752,404L104.818,409.917C93.884,415.833,72.016,427.667,65.707,438.993C59.398,450.32,68.648,461.14,73.273,466.55L77.898,471.96" id="d-9c432e0a5e48-L_C_X_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_X_0" data-points="W3sieCI6MTE1Ljc1MTYwMDkyMjEzMTE1LCJ5Ijo0MDR9LHsieCI6NTAuMTQ4NDM3NSwieSI6NDM5LjV9LHsieCI6ODAuNDk3MTE4MzQwMTYzOTQsInkiOjQ3NX1d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M162.875,404L162.875,409.917C162.875,415.833,162.875,427.667,157.469,439.027C152.063,450.387,141.251,461.275,135.845,466.718L130.439,472.162" id="d-9c432e0a5e48-L_C_X_2" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_X_2" data-points="W3sieCI6MTYyLjg3NSwieSI6NDA0fSx7IngiOjE2Mi44NzUsInkiOjQzOS41fSx7IngiOjEyNy42MjA1MTc0MTgwMzI3OSwieSI6NDc1fV0=" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M220.882,404L234.341,409.917C247.8,415.833,274.718,427.667,288.178,438.833C301.637,450,301.637,460.5,301.637,465.75L301.637,471" id="d-9c432e0a5e48-L_C_F_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_F_0" data-points="W3sieCI6MjIwLjg4MTk0ODAwMjA0OTE3LCJ5Ijo0MDR9LHsieCI6MzAxLjYzNjcxODc1LCJ5Ijo0MzkuNX0seyJ4IjozMDEuNjM2NzE4NzUsInkiOjQ3NX1d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M102.297,526L102.297,530.167C102.297,534.333,102.297,542.667,121.664,552.549C141.031,562.431,179.765,573.862,199.132,579.578L218.5,585.294" id="d-9c432e0a5e48-L_X_U_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_X_U_0" data-points="W3sieCI6MTAyLjI5Njg3NSwieSI6NTI2fSx7IngiOjEwMi4yOTY4NzUsInkiOjU1MX0seyJ4IjoyMjIuMzM1OTM3NSwieSI6NTg2LjQyNTg1NDkwNTcyMDd9XQ==" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M491.715,526L491.715,530.167C491.715,534.333,491.715,542.667,464.494,553.13C437.273,563.594,382.831,576.188,355.61,582.485L328.389,588.782" id="d-9c432e0a5e48-L_P_U_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_P_U_0" data-points="W3sieCI6NDkxLjcxNDg0Mzc1LCJ5Ijo1MjZ9LHsieCI6NDkxLjcxNDg0Mzc1LCJ5Ijo1NTF9LHsieCI6MzI0LjQ5MjE4NzUsInkiOjU4OS42ODM5ODQ5NjkxMzN9XQ==" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(162.875, 94.5)"><g class="label" data-id="L_N_CD_0" transform="translate(-96.9375, -10.5)"><foreignObject width="193.875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>asked from the internet</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(491.71484375, 216.5)"><g class="label" data-id="L_N_TD_0" transform="translate(-71.6484375, -10.5)"><foreignObject width="143.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>asked from my LAN</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(162.875, 216.5)"><g class="label" data-id="L_CD_E_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>104.21.x.x</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(491.71484375, 439.5)"><g class="label" data-id="L_TD_P_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>10.0.0.200</p></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_E_C_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(50.1484375, 439.5)"><g class="label" data-id="L_C_X_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>/bundle.js</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(162.875, 439.5)"><g class="label" data-id="L_C_X_2" transform="translate(-50.578125, -10.5)"><foreignObject width="101.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>/api/v1/data</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(301.63671875, 439.5)"><g class="label" data-id="L_C_F_0" transform="translate(-63.21875, -10.5)"><foreignObject width="126.4375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>everything else</p></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_X_U_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_P_U_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default" id="d-9c432e0a5e48-flowchart-N-0" data-look="classic" transform="translate(329.77734375, 33.5)"><rect class="basic label-container" style="" x="-143.7890625" y="-25.5" width="287.578125" height="51"/><g class="label" style="" transform="translate(-113.7890625, -10.5)"><rect/><foreignObject width="227.578125" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>analytics.nightcity.network</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-CD-2" data-look="classic" transform="translate(162.875, 155.5)"><rect class="basic label-container" style="" x="-89.0078125" y="-25.5" width="178.015625" height="51"/><g class="label" style="" transform="translate(-59.0078125, -10.5)"><rect/><foreignObject width="118.015625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Cloudflare DNS</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-TD-4" data-look="classic" transform="translate(491.71484375, 378.5)"><rect class="basic label-container" style="" x="-72.1484375" y="-25.5" width="144.296875" height="51"/><g class="label" style="" transform="translate(-42.1484375, -10.5)"><rect/><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Technitium</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-E-6" data-look="classic" transform="translate(162.875, 277.5)"><rect class="basic label-container" style="" x="-93.21875" y="-25.5" width="186.4375" height="51"/><g class="label" style="" transform="translate(-63.21875, -10.5)"><rect/><foreignObject width="126.4375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Cloudflare edge</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-P-8" data-look="classic" transform="translate(491.71484375, 500.5)"><rect class="basic label-container" style="" x="-97.4296875" y="-25.5" width="194.859375" height="51"/><g class="label" style="" transform="translate(-67.4296875, -10.5)"><rect/><foreignObject width="134.859375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>My reverse proxy</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-C-10" data-look="classic" transform="translate(162.875, 378.5)"><rect class="basic label-container" style="" x="-76.359375" y="-25.5" width="152.71875" height="51"/><g class="label" style="" transform="translate(-46.359375, -10.5)"><rect/><foreignObject width="92.71875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>cloudflared</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-X-12" data-look="classic" transform="translate(102.296875, 500.5)"><rect class="basic label-container" style="" x="-51.078125" y="-25.5" width="102.15625" height="51"/><g class="label" style="" transform="translate(-21.078125, -10.5)"><rect/><foreignObject width="42.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>nginx</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-F-16" data-look="classic" transform="translate(301.63671875, 500.5)"><rect class="basic label-container" style="" x="-42.6484375" y="-25.5" width="85.296875" height="51"/><g class="label" style="" transform="translate(-12.6484375, -10.5)"><rect/><foreignObject width="25.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>404</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-U-18" data-look="classic" transform="translate(273.4140625, 601.5)"><rect class="basic label-container" style="" x="-51.078125" y="-25.5" width="102.15625" height="51"/><g class="label" style="" transform="translate(-21.078125, -10.5)"><rect/><foreignObject width="42.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Umami</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="d-9c432e0a5e48-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><defs><filter id="d-9c432e0a5e48-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><linearGradient id="d-9c432e0a5e48-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#3c3f43" stop-opacity="1"/><stop offset="100%" stop-color="hsl(216, 0%, 23.5294117647%)" stop-opacity="1"/></linearGradient></svg></figure><p><code>cloudflared</code> decides what gets through and it&rsquo;s only aware of two paths. My side of the picture never touches Cloudflare, or the rest of the internet, at all. That shared fork at the top is possible with split horizon DNS and which route your request takes depends on how you get to that point. I&rsquo;ll explain a bit further down.</p>
<h2 id="why-i-think-this-is-the-better-trade">
  <a class="heading-link" href="#why-i-think-this-is-the-better-trade">Why I think this is the better trade.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I want to be fair to Access here, because it&rsquo;s a great product and there are plenty of setups where it&rsquo;s the right call. If you genuinely need to reach an admin panel from anywhere on a machine you don&rsquo;t control, without a VPN, Access is exactly the kind of tool that&rsquo;ll get you there.</p>
<p>But if you don&rsquo;t need that, consider what you&rsquo;re actually buying in to.</p>
<p>With Access, <code>/login</code> is on the internet. It returns a challenge instead of a form, which is much better than nothing, but the <em>attack surface</em> is still there. There&rsquo;s an auth flow to configure, an IDP to keep working and an additional sign-in every time. Umami doesn&rsquo;t consume the Access token, so you authenticate to Cloudflare and then authenticate again to Umami. Nobody has time for that nonsense.</p>
<p>With path scoping, <code>/login</code> returns a <code>404</code>. There is no flow to misconfigure because there is no flow. There is nothing to leave accidentally open when you change something else eighteen months from now.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Elimination is the ultimate form of hardening.</p>
  </blockquote>
  <figcaption>
    <cite>Pretty much every cybersecurity professional.</cite>
  </figcaption>
</figure>

<p>The thing I keep coming back to: <strong>an attack surface you removed cannot be misconfigured later.</strong> A control you added can. This is all about shrinking the <a href="https://en.wikipedia.org/wiki/Attack_surface">attack surface</a>, which is an idea that&rsquo;s far older than me. We&rsquo;re not doing anything new here. But a standard installation path is built to get the typical user up and running quickly. However, quick doesn&rsquo;t necessarily mean appropriate for something like this.</p>
<p>Worth being precise about which of these is which, though. The <code>404</code> on <code>/login</code> is absence. There is nothing behind it to find no matter how hard you look. Umami does allow you to rename its default tracker paths as well, but that&rsquo;s plainly obscurity and it buys me nothing against anyone who actually takes the time to read my page source. Outside of that, renaming dodges a blocklist that matches on names and known patterns.</p>
<h2 id="does-it-work-though">
  <a class="heading-link" href="#does-it-work-though">Does it work though?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The nice part about this setup is that you can test the public path without leaving the house. Using <code>curl --resolve</code> lets you skip your own DNS and dial the Cloudflare edge directly, while still sending the right hostname, so the request takes the same road a real visitor does.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sI <span class="se">\
</span></span></span><span class="line"><span class="cl">  --resolve analytics.nightcity.network:443:104.21.x.x <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://analytics.nightcity.network/login <span class="p">|</span> head -1</span></span></code></pre></div>
</div>
<p>Run that against a handful of paths and you get:</p>
<div class="code-block">
  <pre tabindex="0"><code>/                  404
/login             404
/bundle.js         200
/script.js         404
/api/v1/data       405
/api/send          404</code></pre>
</div>
<p>That <code>405</code> is the collect endpoint telling me it only accepts <code>POST</code>, which is exactly what I want to see from a <code>GET</code>. It does admit the endpoint exists, where a <code>404</code> would not, but that one has to be findable anyway. Everything else gets a polite middle finger.</p>
<p>Meanwhile, from inside the house, the same hostname gives me the full dashboard. The same two path as before and the split falls out of the DNS I already run for network-wide adblock via a Technitium cluster.</p>
<h3 id="something-worth-pointing-out">
  <a class="heading-link" href="#something-worth-pointing-out">Something worth pointing out.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I updated the ingress config, re-ran my checks and <code>/script.js</code> came back with a <code>200</code>. Which was alarming, because I had just watched it <code>404</code>.</p>
<p>It was cached. Umami serves its tracker with a 24-hour cache header. My earlier testing had pulled it through the edge and Cloudflare was dutifully serving me the copy it already had. Which, to be fair, is exactly what it should normally be doing. A request with a junk query string came back <code>404</code> immediately confirming the origin was doing its job.</p>
<p>Worth knowing if you ever unpublish something: the edge does not find out until its copy expires. Cache invalidation strikes again!</p>
<h2 id="hark-a-plot-twist">
  <a class="heading-link" href="#hark-a-plot-twist">Hark! A plot twist!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Just after I shipped the script tag and confirmed real traffic was hitting Umami, Firefox showed me this:</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: The Firefox popup for Local Network Access.">
      <img
  src="https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_cb80f6f98d090c39.webp"
  srcset="/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_cb80f6f98d090c39.webp 736w, /blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_74200a685c603811.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="308"
  alt="The Firefox popup for Local Network Access."
  loading="lazy"
  decoding="async"
  data-full="/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_53a1cc4f5ba18d12.webp"
  data-credit="Go home Firefox, you are drunk."
  
/>

    </button>
    
      <figcaption>
        
          Go home Firefox, you are drunk.
        
      </figcaption>
    
  </figure>
<p>Sir, this is my own site from my machine on my home network. How <em>dare</em> you?</p>
<p>As I mentioned earlier, I run split horizon DNS. Inside my network, <code>analytics.nightcity.network</code> resolves to a private address on my LAN so I get the dashboard directly. Outside, where <em>you better be</em> reading from, the same name resolves to Cloudflare and goes down the tunnel. That&rsquo;s what makes the two-path thing work. I also mentioned this before in my previous article <a href="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/">My Blog Now Ships From My Homelab!</a> and it&rsquo;s one of my favourite aspects about my setup.</p>
<p>But when I load my <em>public</em> blog from my <em>own house</em>, the browser sees a page served from a public origin trying to load a subresource from <code>10.x.x.x</code>. Browsers have started treating that as exactly the attack it usually is: a website on the internet quietly poking at your router, your printer or your NAS. Firefox calls it <a href="https://support.mozilla.org/en-US/kb/control-personal-device-local-network-permissions-firefox">Local Network Access</a> and blocks it by default. Chrome is heading the same way.</p>
<p>So the one place on earth where my analytics silently do not work is the chair I&rsquo;m sitting in which is precisely how it should work. If it ever bothers me, the fix is to give the tracker its own hostname in a domain my internal resolver doesn&rsquo;t answer for, so it goes out to the edge from everywhere including my lounge room. I won&rsquo;t be doing that as I&rsquo;m all too happy skipping my own page views.</p>
<h2 id="the-one-thing-path-scoping-does-not-fix">
  <a class="heading-link" href="#the-one-thing-path-scoping-does-not-fix">The one thing path scoping does not fix.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Moving the admin interface off the internet does nothing for the collect endpoint. That endpoint has to accept anonymous <code>POST</code>s from every visitor&rsquo;s browser, or it isn&rsquo;t an analytics endpoint. Umami rejects requests carrying a website ID it doesn&rsquo;t recognise, which stops idle nonsense, but my real website ID is sitting in the page source of every page on this site and <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span>. Literally anyone can read it.</p>
<p>So the only real control is applying some kind of rate limit. Cloudflare&rsquo;s free tier, which I&rsquo;m currently on because I&rsquo;m cheap and actually trying to <em>reduce</em> my subscription costs, permits one rule at a ten second window and it insists on counting per <em>data centre</em> rather than <em>globally</em>. It&rsquo;ll stop something stupid. But, it will not stop something determined.</p>
<p>Basically, path scoping shrinks the surface area enormously and then stops. What&rsquo;s left is the stuff that&rsquo;s <em>supposed</em> to be open. Though, the worst that can happen if the service gets hammered is fill my disk with garbage data points. Which would no-shit make me laugh.</p>
<h2 id="-so-naturally-i-put-nginx-in-front-of-it">
  <a class="heading-link" href="#-so-naturally-i-put-nginx-in-front-of-it">&hellip; so, naturally I put Nginx in front of it.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I told myself this was about the rate limit which was a lie. It took a few hours for two better reasons to turn up.</p>
<h3 id="the-origin-is-blind">
  <a class="heading-link" href="#the-origin-is-blind">The origin is blind.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>A tunnel terminates the connection at the edge and opens a fresh one to your box, so every request arrives from the connector sitting next to your service. Umami sees <code>172.16.x.x</code>, the Docker address of <code>cloudflared</code>, for the entire internet. That&rsquo;s also why the usual advice of &ldquo;just put fail2ban on it&rdquo; doesn&rsquo;t really apply. <a href="https://en.wikipedia.org/wiki/Fail2ban">Fail2ban</a> blocks source addresses with a firewall rule and no packet ever arrives from the address you&rsquo;d want to block.</p>
<p>Cloudflare does pass the real client along in a <code>CF-Connecting-IP</code> header. Nginx can promote that header back into being the actual client address, which makes both the logging and the limiting mean something:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Nginx</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">set_real_ip_from</span> <span class="n">172.16.0.0</span><span class="s">/12</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">real_ip_header</span>   <span class="s">CF-Connecting-IP</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">limit_req_zone</span> <span class="nv">$binary_remote_addr</span> <span class="s">zone=collect:10m</span> <span class="s">rate=5r/s</span><span class="p">;</span></span></span></code></pre></div>
</div>
<p>The <code>set_real_ip_from</code> range tells Nginx only the connector may assert that header. Nginx answers <code>cloudflared</code> and nothing else here and my internal route goes straight to Umami without passing through it. Meaning nothing on my LAN can forge a client address at me.</p>
<h3 id="umami-logs-nothing-at-all-zilch">
  <a class="heading-link" href="#umami-logs-nothing-at-all-zilch">Umami logs nothing at all. Zilch.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Its own logs are four lines of startup and then a blank stare into the void. Which means that until I put a proxy in front of it, the only record of what hit my analytics endpoint was&hellip; my analytics.</p>
<p>Nginx gave me an access log within about ten seconds of starting and the very first thing it told me was that I had no idea what my traffic was.</p>
<h2 id="in-which-i-am-humbled-by-a-robot">
  <a class="heading-link" href="#in-which-i-am-humbled-by-a-robot">In which I am humbled by a robot.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I had been suprised with my initial visitor numbers. Then I looked properly and roughly ~90% of it was a single machine in Singapore hammering one <a href="https://wilhelm.codes/bits/update-putting-the-site-on-a-diet/">tiny post</a>:</p>
<div class="code-block">
  <pre tabindex="0"><code>browser  os      device  screen      country  hits  sessions
chrome   Mac OS  laptop  1366x1366   SG       53    51</code></pre>
</div>
<p>There are a few tells here. Not only is the screen size a perfect square, <code>50</code>ish sessions with just as many hits from the same region with the same browser settings smells like a bot crawl. It was Kagi, near as I can tell, rendering the page to build a preview for <a href="https://kagi.com/smallweb">Kagi Small Web</a>. I have no real objection to that. It reaches my tracker at all because it runs the JavaScript, which an ordinary crawler never would. This is exactly the sort of thing that pollutes a dataset while you sit there feeling popular.</p>
<p>I don&rsquo;t care about the bots. The point is that I only found it because I&rsquo;d added logging. I only added logging because I was chasing a rate limit I probably didn&rsquo;t need.</p>
<h2 id="the-small-detail-i-liked-most">
  <a class="heading-link" href="#the-small-detail-i-liked-most">The small detail I liked most.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Nginx has a default location block and mine looks like this:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Nginx</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">return</span> <span class="mi">444</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p><code>444</code> is an <a href="https://http.dev/444#configuring-444">Nginx special</a>. It doesn&rsquo;t return a status, but closes the connection quietly.</p>
<p>That rule is doing the same job as the catch-all <code>404</code> in my tunnel ingress. It holds exactly the same line only hop further in. Two independent things now have to be wrong at once before anything reaches my admin interface.</p>
<p>So, the tunnel is Cloudflare&rsquo;s. The edge rate limit is Cloudflare&rsquo;s. The Nginx config is a text file that&rsquo;s <em>mine</em> and it doesn&rsquo;t care in the slightest what is in front of it. Which matters, because I&rsquo;m not 100% sold on keeping Cloudflare around longer-term.</p>
<p>None of the idea is Cloudflare-specific either. Any reverse proxy has a path-scoped location block and any tunnel worth the name matches on more than a hostname. <a href="https://tailscale.com/">Tailscale</a>, <a href="https://pangolin.net/">Pangolin</a>, <a href="https://netbird.io/">NetBird</a> or even vanilla <a href="https://www.wireguard.com/">WireGuard</a> will get you there. I picked Tunnels as a convenient stop-gap until I move on to something a bit more &ldquo;self-hosty&rdquo;. The vendor is incidental. Publish only what <em>has</em> to be public.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>None of this is novel. Tunnels have matched on path since forever, it&rsquo;s in the <a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched">docs</a> and I&rsquo;m certain plenty of people are already doing exactly this. I just hadn&rsquo;t thought about it properly, because the well-trodden advice is &ldquo;put an auth layer in front of it&rdquo; and this kind of consensus is usually good enough that you stop thinking.</p>
<p>But &ldquo;protect the admin panel&rdquo; and &ldquo;publish the admin panel and <em>then</em> protect it&rdquo; are not the same sentence and you should be aware of the difference.</p>
<p>If you&rsquo;re running anything through a tunnel right now, it&rsquo;s worth five minutes with your ingress config. Ask yourself which paths actually <em>need</em> to leave your network. For a lot of self-hosted things the honest answer is &ldquo;fewer than all of them&rdquo;. The best answer is &ldquo;none&rdquo;. And if you&rsquo;d rather find out than wonder, that <code>curl --resolve</code> trick works against any hostname, including yours. Start with <code>/login</code> and see what comes back.</p>
<p>And if you&rsquo;re about to tell me the browser permission dialog is a bug, it isn&rsquo;t. It&rsquo;s three correct systems arguing and I&rsquo;ve decided that&rsquo;s a feature.</p>
<h2 id="would-you-like-to-know-more">
  <a class="heading-link" href="#would-you-like-to-know-more">Would you like to know more?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I kept the theory light in the post, because it kind of goes against the vibe of the site and my own casual style of writing. But the ideas underneath this are old and much better argued elsewhere, so here&rsquo;s where I&rsquo;d start.</p>
<ul>
<li><a href="https://web.mit.edu/Saltzer/www/publications/protection/">The Protection of Information in Computer Systems</a>, Saltzer and Schroeder, 1975. The source of <em>fail-safe defaults</em> ( deny by default, allow by exception, which is exactly what that catch-all <code>404</code> rule is ) and <em>economy of mechanism</em> ( fewer moving parts means fewer parts to get wrong ). It&rsquo;s a fifty year old paper and it reads like one, but section I is short and it has aged as disgracefully as I have.</li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Attack_Surface_Analysis_Cheat_Sheet.html">Attack Surface Analysis</a>, OWASP. The plain-English version of the whole post, minus my nonsense. Useful if you want a structured way to ask &ldquo;what am I actually exposing&rdquo; about something larger than a script tag.</li>
<li><a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched">Tunnel configuration file</a>, Cloudflare. The ingress rules reference. This is the page I had skimmed past twice before noticing that <code>path</code> was sitting right there next to <code>hostname</code>.</li>
<li><a href="https://wicg.github.io/local-network-access/">Local Network Access</a>, WICG. The spec behind the permission dialog that made me raise an eyebrow. If you&rsquo;d rather have prose than a spec, Chrome&rsquo;s <a href="https://developer.chrome.com/blog/local-network-access">New permission prompt for Local Network Access</a> covers the same ground and explains why browsers decided this needed fixing.</li>
</ul>
<p>If you only read one, make it Saltzer and Schroeder. Almost everything I thought I worked out on that Saturday turns out to be in there, but described better by people who got to it first and who are <em>far</em> smarter than me.</p>]]></content:encoded></item><item><title>My Blog Now Ships From My Homelab!</title><link>https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/</link><pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/</guid><category>hugo</category><category>woodpecker</category><category>forgejo</category><category>cloudflare</category><category>homelab</category><wc:kind>post</wc:kind><description>If you&amp;rsquo;ve given GitHub&amp;rsquo;s status page a quick glance recently, you&amp;rsquo;ll immediately see that it&amp;rsquo;s been going through it. Every outage is a small reminder that maybe we shouldn&amp;rsquo;t rely too much on single points of failure we can&amp;rsquo;t directly control.
So, having been bitten several times by intermittent outages and service disruptions, I figured a small personal win would be to migrate the repository for this site to the homelab. Now, it lives on my own Forgejo forge and the builds run on my own Woodpecker cluster. The only things that leave the network are static build artefacts that get pushed up to Cloudflare Pages.
The switch was surprisingly straightforward and took less than 30 minutes overall.</description><content:encoded><![CDATA[<p>If you&rsquo;ve given GitHub&rsquo;s status page a quick glance recently, you&rsquo;ll immediately see that it&rsquo;s been <em>going through it</em>. Every outage is a small reminder that maybe we shouldn&rsquo;t rely too much on single points of failure we can&rsquo;t directly control.</p>
<p>So, having been bitten several times by intermittent outages and service disruptions, I figured a small personal win would be to migrate the repository for <em>this</em> site to the homelab. Now, it lives on my own <a href="https://forgejo.org">Forgejo</a> forge and the builds run on my own <a href="https://woodpecker-ci.org">Woodpecker</a> cluster. The only things that leave the network are static build artefacts that get pushed up to Cloudflare Pages.</p>
<p>The switch was surprisingly straightforward and took less than 30 minutes overall.</p>
<p>Before anyone reaches for the comment box, no, you don&rsquo;t need a homelab for any of this. Forgejo and a Woodpecker cluster ( server and at least one agent ) will happily run on a spare mini PC, an old laptop, that Raspberry Pi you bought with the best of intentions or even as a single Docker Compose file running on the machine you use every day.</p>
<h2 id="how-complex-can-this-possibly-be">
  <a class="heading-link" href="#how-complex-can-this-possibly-be">How complex can this possibly be?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The first useful thing I did was ask myself what GitHub was <em>actually</em> doing for me when it comes to managing this site. After some mulling about, I could only really list the following:</p>
<ul>
<li>Hosting a git repository.</li>
<li>Hosting the comment threads, via <a href="https://giscus.app">giscus</a> Discussions.</li>
</ul>
<p>That&rsquo;s pretty much it. I don&rsquo;t use Actions and GitHub alone never built this site. <a href="https://pages.cloudflare.com">Cloudflare Pages</a> watched the <code>main</code> branch and did the building itself. This is something I covered back in <a href="https://wilhelm.codes/blog/my-blog-publishing-setup/">My Blog Publishing Setup</a>. GitHub&rsquo;s one job was holding a bunch of markdown files.</p>
<p>That&rsquo;s a strange thing to accept as a single point of failure.</p>
<h2 id="the-new-shape-of-things">
  <a class="heading-link" href="#the-new-shape-of-things">The new shape of things.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The replacement looks like this:</p>
<ul>
<li>I push to Forgejo, which lives inside my homelab network.</li>
<li>Forgejo fires off a message to the Woodpecker Server over a webhook. Also inside the network.</li>
<li>The Woodpecker Server assigns the job to an available Woodpecker Agent, which clones the repo, builds the site with Hugo and pushes the result to Cloudflare Pages with <a href="https://developers.cloudflare.com/workers/wrangler/">wrangler</a>.</li>
<li>Cloudflare serves the files at the edge, exactly as before.</li>
</ul>
<p>I just added support for mermaid diagrams, so of course I&rsquo;m not going to let the opportunity to show it off slide:</p>
<figure class="diagram"><svg id="d-702390838d2d" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="max-width: 865px; background-color: transparent;" viewBox="-50 -10 865 421" role="graphics-document document" aria-roledescription="sequence"><g><rect x="615" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="C" rx="3" ry="3" class="actor actor-bottom"/><text x="690" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="690" dy="0">Pages</tspan></text></g><g><rect x="400" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-bottom"/><text x="475" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="475" dy="0">Woodpecker</tspan></text></g><g><rect x="200" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="F" rx="3" ry="3" class="actor actor-bottom"/><text x="275" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="275" dy="0">Forgejo</tspan></text></g><g><rect x="0" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-bottom"/><text x="75" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">The desk</tspan></text></g><g><line id="actor3" x1="690" y1="65" x2="690" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="C" data-et="life-line" data-id="C"/><g id="root-3" data-et="participant" data-type="participant" data-id="C"><rect x="615" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="C" rx="3" ry="3" class="actor actor-top"/><text x="690" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="690" dy="0">Pages</tspan></text></g></g><g><line id="actor2" x1="475" y1="65" x2="475" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="W" data-et="life-line" data-id="W"/><g id="root-2" data-et="participant" data-type="participant" data-id="W"><rect x="400" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-top"/><text x="475" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="475" dy="0">Woodpecker</tspan></text></g></g><g><line id="actor1" x1="275" y1="65" x2="275" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="F" data-et="life-line" data-id="F"/><g id="root-1" data-et="participant" data-type="participant" data-id="F"><rect x="200" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="F" rx="3" ry="3" class="actor actor-top"/><text x="275" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="275" dy="0">Forgejo</tspan></text></g></g><g><line id="actor0" x1="75" y1="65" x2="75" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="D" data-et="life-line" data-id="D"/><g id="root-0" data-et="participant" data-type="participant" data-id="D"><rect x="0" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-top"/><text x="75" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">The desk</tspan></text></g></g><style>#d-702390838d2d{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;fill:#32d74b;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#d-702390838d2d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#d-702390838d2d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#d-702390838d2d .error-icon{fill:#202225;}#d-702390838d2d .error-text{fill:#e3e3de;stroke:#e3e3de;}#d-702390838d2d .edge-thickness-normal{stroke-width:1px;}#d-702390838d2d .edge-thickness-thick{stroke-width:3.5px;}#d-702390838d2d .edge-pattern-solid{stroke-dasharray:0;}#d-702390838d2d .edge-thickness-invisible{stroke-width:0;fill:none;}#d-702390838d2d .edge-pattern-dashed{stroke-dasharray:3;}#d-702390838d2d .edge-pattern-dotted{stroke-dasharray:2;}#d-702390838d2d .marker{fill:#8b8b84;stroke:#8b8b84;}#d-702390838d2d .marker.cross{stroke:#8b8b84;}#d-702390838d2d svg{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;}#d-702390838d2d p{margin:0;}#d-702390838d2d .actor{stroke:#3c3f43;fill:#1e1f22;stroke-width:1;}#d-702390838d2d rect.actor.outer-path[data-look="neo"]{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d rect.note[data-look="neo"]{stroke:hsl(52.6829268293, 60%, 93.9215686275%);fill:#fff5ad;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d text.actor&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .actor-line{stroke:#8b8b84;}#d-702390838d2d .innerArc{stroke-width:1.5;stroke-dasharray:none;}#d-702390838d2d .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#8b8b84;}#d-702390838d2d .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#8b8b84;}#d-702390838d2d [id$="-arrowhead"] path{fill:#8b8b84;stroke:#8b8b84;}#d-702390838d2d .sequenceNumber{fill:#74747b;}#d-702390838d2d [id$="-sequencenumber"]{fill:#8b8b84;}#d-702390838d2d [id$="-crosshead"] path{fill:#8b8b84;stroke:#8b8b84;}#d-702390838d2d .messageText{fill:#32d74b;stroke:none;}#d-702390838d2d .labelBox{stroke:#3c3f43;fill:#1e1f22;filter:none;}#d-702390838d2d .labelText,#d-702390838d2d .labelText&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .loopText,#d-702390838d2d .loopText&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .sectionTitle,#d-702390838d2d .sectionTitle&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#3c3f43;fill:#3c3f43;}#d-702390838d2d .note{stroke:hsl(52.6829268293, 60%, 93.9215686275%);fill:#fff5ad;}#d-702390838d2d .noteText,#d-702390838d2d .noteText&gt;tspan{fill:#333;stroke:none;font-weight:normal;}#d-702390838d2d .activation0{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-702390838d2d .activation1{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-702390838d2d .activation2{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-702390838d2d .actorPopupMenu{position:absolute;}#d-702390838d2d .actorPopupMenuPanel{position:absolute;fill:#1e1f22;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#d-702390838d2d .actor-man circle,#d-702390838d2d line{fill:#1e1f22;stroke-width:2px;}#d-702390838d2d g rect.rect{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));stroke:#3c3f43;}#d-702390838d2d .node .neo-node{stroke:#3c3f43;}#d-702390838d2d [data-look="neo"].node rect,#d-702390838d2d [data-look="neo"].cluster rect,#d-702390838d2d [data-look="neo"].node polygon{stroke:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].swimlane.cluster rect{filter:none;}#d-702390838d2d [data-look="neo"].node path{stroke:url(#d-702390838d2d-gradient);stroke-width:1px;}#d-702390838d2d [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].node .neo-line path{stroke:#3c3f43;filter:none;}#d-702390838d2d [data-look="neo"].node circle{stroke:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].node circle .state-start{fill:#000000;}#d-702390838d2d [data-look="neo"].icon-shape .icon{fill:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].icon-shape .icon-neo path{stroke:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g/><defs><symbol id="d-702390838d2d-computer" width="24" height="24"><path transform="scale(.5)" d="M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z"/></symbol></defs><defs><symbol id="d-702390838d2d-database" fill-rule="evenodd" clip-rule="evenodd"><path transform="scale(.5)" d="M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z"/></symbol></defs><defs><symbol id="d-702390838d2d-clock" width="24" height="24"><path transform="scale(.5)" d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z"/></symbol></defs><defs><marker id="d-702390838d2d-arrowhead" refX="7.9" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M -1 0 L 10 5 L 0 10 z"/></marker></defs><defs><marker id="d-702390838d2d-crosshead" markerWidth="15" markerHeight="8" orient="auto" refX="4" refY="4.5"><path fill="none" stroke="#000000" stroke-width="1pt" d="M 1,2 L 6,7 M 6,2 L 1,7" style="stroke-dasharray: 0, 0;"/></marker></defs><defs><marker id="d-702390838d2d-filled-head" refX="15.5" refY="7" markerWidth="20" markerHeight="28" orient="auto"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker id="d-702390838d2d-sequencenumber" refX="15" refY="15" markerWidth="60" markerHeight="40" orient="auto"><circle cx="15" cy="15" r="6"/></marker></defs><defs><marker id="d-702390838d2d-solidTopArrowHead" refX="7.9" refY="7.25" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 8 L 0 8 z"/></marker></defs><defs><marker id="d-702390838d2d-solidBottomArrowHead" refX="7.9" refY="0.75" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 0 L 0 8 z"/></marker></defs><defs><marker id="d-702390838d2d-stickTopArrowHead" refX="7.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 7 7" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><defs><marker id="d-702390838d2d-stickBottomArrowHead" refX="7.5" refY="0" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 7 L 7 0" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><text x="174" y="80" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">git push ( main )</text><line x1="76" y1="109" x2="271" y2="109" class="messageLine0" data-et="message" data-id="i0" data-from="D" data-to="F" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="374" y="124" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">webhook</text><line x1="276" y1="153" x2="471" y2="153" class="messageLine0" data-et="message" data-id="i1" data-from="F" data-to="W" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="476" y="168" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">diagrams, changelog, hugo --gc</text><path d="M 476,197 C 536,187 536,227 476,217" class="messageLine0" data-et="message" data-id="i2" data-from="W" data-to="W" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="581" y="242" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">wrangler pages deploy</text><line x1="476" y1="271" x2="686" y2="271" class="messageLine0" data-et="message" data-id="i3" data-from="W" data-to="C" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="384" y="286" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">the same site, but newer</text><line x1="689" y1="315" x2="79" y2="315" class="messageLine1" data-et="message" data-id="i4" data-from="C" data-to="D" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"/></svg></figure><p>The part I want you to notice is the direction of travel. Under the old setup, Cloudflare reached <em>into</em> GitHub to pull my source. Under the new one, nothing reaches in at all. The forge and the CI server accept no traffic from the internet. The only packet that crosses the network boundary is an <em>outbound</em> upload of compiled static assets.</p>
<p>That reversal is the entire security model which was pretty much the point. What looks like &ldquo;the old way but more steps&rdquo; allows me to control every aspect of the pipeline up to <em>just</em> before we ship the build artefacts. A push-based deploy means self-hosting your forge requires exposing exactly nothing. No reverse proxy gymnastics, no VPN for the webhook and no port forwarding required.</p>
<p>If my homelab bursts into flames mid-afternoon ( a real possibility as I <em>do</em> live in Queensland, Australia ) Cloudflare keeps serving the last deploy and nobody visiting the site would ever know. That being said, nobody actually <em>does</em> visit this site, so&hellip;</p>
<h2 id="a-small-intermission">
  <a class="heading-link" href="#a-small-intermission">A small intermission.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A short aside, because it&rsquo;s a question <em>I&rsquo;d</em> ask. My internal services hang off a domain I genuinely own, but resolving it depends on where you&rsquo;re standing. Inside the network a locally hosted 3-node <a href="https://technitium.com/dns/">Technitium</a> cluster runs the hosted zone and the router sends all DNS requests through it. Initially, I used this for network-wide adblock, which works an absolute treat. It also means the forge and the CI cluster resolve to their internal addresses. Outside the network, the public zone points at a landing page hosted elsewhere.</p>
<p>Same hostname and two completely different worlds depending on which side of the wall you&rsquo;re on. <a href="https://en.wikipedia.org/wiki/Split-horizon_DNS">Split-horizon DNS</a> is nothing new, but it&rsquo;s what lets me use real TLS-friendly hostnames for internal services without those services ever being reachable from the internet.</p>
<p>If I <em>did</em> decide to open some services to the outside world, like my Navidrome server, I could use an overlay network like <a href="https://netbird.io">Netbird</a> to provide friends and family access via a familiar naming convention without ever opening a port on the router.</p>
<h2 id="the-part-cloudflare-doesnt-clearly-advertise">
  <a class="heading-link" href="#the-part-cloudflare-doesnt-clearly-advertise">The part Cloudflare doesn&rsquo;t clearly advertise.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A Pages project is either connected to a git provider or it accepts direct uploads. Never both. As long as your project is wired to GitHub, <code>wrangler pages deploy</code> will be rejected and you will get screamed at.</p>
<p>Disconnect the git integration in the project settings and the project quietly converts to &ldquo;Direct Upload&rdquo; mode. It keeps its name, its custom domain and its whole deployment history. It just stops watching a repository and starts waiting for you to hand it those delicious files. While you&rsquo;re in there, make sure the production branch is properly specified: <code>main</code> in my case. This matters because Wrangler tags each upload with a branch name. An upload whose branch matches the production branch goes live on the site. An upload under any other name becomes a preview deployment with its own URL instead.</p>
<p>After that, the deploy is one command requiring two secrets:</p>
<ul>
<li>An API token scoped to <code>Cloudflare Pages: Edit</code></li>
<li>Your Cloudflare account ID.</li>
</ul>
<p>Both go into the Woodpecker Server as repository secrets.</p>
<h2 id="finally-our-favourite-yaml">
  <a class="heading-link" href="#finally-our-favourite-yaml">Finally, our favourite! YAML.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The whole thing is just one file named <code>.woodpecker.yml</code> that squats in the repository root.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">YAML</span>
  </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">when</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">event</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, manual]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span><span class="l">main</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">clone</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">git</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">woodpeckerci/plugin-git</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">settings</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">partial</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">diagrams</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">minlag/mermaid-cli:11.16.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">commands</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./bin/render-diagrams</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="w">  </span><span class="nt">build</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">hugomods/hugo:debian-git-0.163.3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HUGO_ENV</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HUGO_ENVIRONMENT</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">commands</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">git config --global --add safe.directory &#39;*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./bin/changelog</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">hugo --gc</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="w">  </span><span class="nt">deploy</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">node:22-slim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">CLOUDFLARE_API_TOKEN</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">from_secret</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare_api_token</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">CLOUDFLARE_ACCOUNT_ID</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">from_secret</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare_account_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">commands</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">        npx --yes wrangler@4 pages deploy public
</span></span></span><span class="line"><span class="cl"><span class="sd">        --project-name=wilhelm-codes
</span></span></span><span class="line"><span class="cl"><span class="sd">        --branch=main
</span></span></span><span class="line"><span class="cl"><span class="sd">        --commit-hash=&#34;${CI_COMMIT_SHA}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">when</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">event</span><span class="p">:</span><span class="w"> </span><span class="l">push</span></span></span></code></pre></div>
</div>
<h3 id="only-build-off-main">
  <a class="heading-link" href="#only-build-off-main">Only build off <code>main</code>.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I test design changes and new entries locally, so a push to any other branch doesn&rsquo;t start a pipeline.</p>
<h3 id="we-want-a-full-clone-not-a-shallow-one">
  <a class="heading-link" href="#we-want-a-full-clone-not-a-shallow-one">We want a full clone, not a shallow one.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>This site&rsquo;s <a href="https://wilhelm.codes/changelog/">changelog page</a> renders the git log and Hugo&rsquo;s <code>enableGitInfo</code> reads commit dates for every page. This is something I wrote about in <a href="https://wilhelm.codes/blog/a-changelog-that-builds-itself/">A Changelog That Builds Itself</a>. CI systems love shallow clones and a shallow clone here means I can&rsquo;t convey the full historical picture on the page. Worse, <code>enableGitInfo</code> derives every article&rsquo;s dates from that same truncated log, so they all come out wrong besides. Setting <code>partial: false</code> makes Woodpecker fetch the entire history.</p>
<h3 id="diagrams-render-before-anything-else">
  <a class="heading-link" href="#diagrams-render-before-anything-else">Diagrams render before anything else.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Articles on this site can embed <a href="https://mermaid.js.org">Mermaid</a> diagrams, like the sequence diagram further up the page. They render to static SVG at build time, not in your browser, so the site ships zero diagram JavaScript. A small script hashes each diagram&rsquo;s source, renders anything that has no matching SVG yet and prunes any SVG nothing references any more. I run it locally and commit the SVGs alongside the content, which means on a clean commit this step does precisely nothing. It exists as a safety net for the day I inevitably forget.</p>
<p>So when I said the diagram was rendered by the pipeline it describes, I meant it literally. If I ever edit that diagram and forget to render it, this step is what quietly saves the deploy.</p>
<h3 id="the-build-step-is-a-stock-hugo-image">
  <a class="heading-link" href="#the-build-step-is-a-stock-hugo-image">The build step is a stock Hugo image.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Nothing is installed on the agent itself unless you count Docker image pulls. The image pins the exact Hugo version, ships the extended edition and includes <code>git</code> and <code>bash</code>. Both of which the changelog script that generates the data requires. The <code>safe.directory</code> line exists because the cloned workspace is owned by a different user than the one inside the container and <code>git</code> these days refuses to read a repository it thinks you stole.</p>
<h3 id="the-deploy-step-is-a-stock-node-image">
  <a class="heading-link" href="#the-deploy-step-is-a-stock-node-image">The deploy step is a stock Node image.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p><code>npx</code> pulls Wrangler at run time. Wrangler then reads the two secrets from the environment and pushes <code>public/</code> up to Pages and that&rsquo;s pretty much it. Dependencies are snagged and cached at build time, which means nothing to maintain on the agents. Either step&rsquo;s version gets bumped by editing one line.</p>
<div class="callout callout-info">
  I already have <a href="https://distribution.github.io">distribution</a> hosted in the homelab for my local Docker images. I could easily configure it to support <a href="https://distribution.github.io/distribution/recipes/mirror/">pull-through caching</a>, so I don&rsquo;t keep having to pull directly from Docker Hub every time a build agent&rsquo;s local cache expires.
</div>

<p>Complete parity with what Cloudflare&rsquo;s own build pipeline was doing, except now I can read every line of it and make it do whatever I need.</p>
<h2 id="i-did-run-into-some-speed-bumps-along-the-way">
  <a class="heading-link" href="#i-did-run-into-some-speed-bumps-along-the-way">I did run into some speed bumps along the way.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>For the record, it took only three tries to get the pipeline to a &ldquo;green&rdquo; state.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: A Woodpecker Server build history screenshot showing the first 3 attempts.">
      <img
  src="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/three-tries_hu_195442048d80ae9b.webp"
  srcset="/blog/my-blog-now-ships-from-my-homelab/three-tries_hu_195442048d80ae9b.webp 736w, /blog/my-blog-now-ships-from-my-homelab/three-tries_hu_ad889cca69fa6327.webp 1104w, /blog/my-blog-now-ships-from-my-homelab/three-tries_hu_132d2f011cbace60.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="232"
  alt="A Woodpecker Server build history screenshot showing the first 3 attempts."
  loading="lazy"
  decoding="async"
  data-full="/blog/my-blog-now-ships-from-my-homelab/three-tries_hu_8fa8b30bf4601afd.webp"
  data-credit="Third time&#39;s a charm."
  
/>

    </button>
    
      <figcaption>
        
          Third time&#39;s a charm.
        
      </figcaption>
    
  </figure>
<p>In order of discovery:</p>
<ul>
<li>The plain version tags of the Hugo image <em>are</em> the extended edition. The <code>reg-</code> prefix means regular and none of them include <code>git</code> unless the tag says <code>git</code>. The tag you guess first ( <code>exts-&lt;version&gt;</code> ) doesn&rsquo;t exist. It&rsquo;s an odd convention, which is why it threw me off.</li>
<li>Woodpecker substitutes <code>${VAR}</code> expressions in the YAML <em>before</em> the shell ever runs. Anything it doesn&rsquo;t know becomes an empty string. My <code>safe.directory &quot;${CI_WORKSPACE}&quot;</code> quietly became <code>safe.directory &quot;&quot;</code>. Hence the asterisk / glob in the YAML.</li>
<li>The changelog script opens with <code>#!/usr/bin/env bash</code> and the default Hugo images are Alpine. <code>env: can't execute 'bash'</code> is the whole error message you get, so I just went with the Debian variants instead which include <code>bash</code> out of the box.</li>
</ul>
<p>None of these are hard problems, but worth pointing out.</p>
<h2 id="im-not-deleting-the-old-repository-completely">
  <a class="heading-link" href="#im-not-deleting-the-old-repository-completely">I&rsquo;m not deleting the old repository&hellip; completely.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The GitHub repository still exists if only to provide a place for the giscus integration to store comments. It offloads most of this functionality to GitHub&rsquo;s Discussions feature. Delete the repo and every comment ever left on this site goes with it; all 2 of them.</p>
<p>It no longer needed the full source history to do that job either. It was ported on over to my local forge with Forgejo&rsquo;s native GitHub repository migration feature. So, I force-pushed the whole history into a black hole, leaving a single commit containing only a README. The remaining husk also has a second job coming; it&rsquo;ll eventually host the companion code for tutorial content, so readers can clone examples without ever touching my forge.</p>
<p>Each service kept exactly the job it&rsquo;s uniquely good at. Cloudflare serves files from the edge better than my house ever could and no fucking way am I publicly opening ports on a router. I&rsquo;d rather lick the rim of a pub toilet. GitHub Discussions gives commenters an identity they already have. Everything else now lives at home.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Was any of this necessary? For a static blog that deploys in under a minute, probably not. The old setup worked fine and if yours does too, keep it. I&rsquo;m not here to convince anyone.</p>
<p>There&rsquo;s a special kind of satisfaction in watching a pipeline run end to end on hardware you can physically point at, knowing the only cloud dependency left is the one actually earning its keep. The next GitHub outage can happen without me. At least for <em>this</em> project anyway.</p>]]></content:encoded></item><item><title>My Blog Publishing Setup &amp; Workflow</title><link>https://wilhelm.codes/blog/my-blog-publishing-setup/</link><pubDate>Sat, 11 Jan 2025 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/my-blog-publishing-setup/</guid><category>obsidian</category><category>hugo</category><category>bash</category><category>cloudflare</category><wc:kind>post</wc:kind><description>Late last year I parted ways with Hashnode as the platform of choice for my blog. They pivoted a bit too far into the generative AI space which — to me for a blogging platform — made little to no sense. What? You write your content with AI, which then trains their model only to spit out more AI-generated slop? A slop-based Ouroboros, if you will. Good luck with that, but I&amp;rsquo;ll pass thanks.</description><content:encoded><![CDATA[<p>Late last year I parted ways with <a href="https://hashnode.com/">Hashnode</a> as the platform of choice for my blog. They pivoted a bit too far into the generative AI space which —  to me for a blogging platform —  made little to no sense. What? You write your content with AI, which then trains their model only to spit out more AI-generated slop?  A slop-based <a href="https://en.wikipedia.org/wiki/Ouroboros">Ouroboros</a>, if you will. Good luck with that, but I&rsquo;ll pass thanks.</p>
<h2 id="first-a-small-rant">
  <a class="heading-link" href="#first-a-small-rant">First, a Small Rant<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Before you call me a <a href="https://en.wikipedia.org/wiki/Luddite">Luddite</a>, let&rsquo;s be clear here, I don&rsquo;t have an issue with the technology itself. I&rsquo;m an engineer. I think it all has its uses, but force-feeding it into almost every aspect of our lives with no regard to any social and environmental impacts with frequently no chance of being able to easily opt-out does not sit right with me one bit.</p>
<p>We&rsquo;ve seen this happen over the last decade with touchscreens, IoT, blockchain, NFTs, Web3 the list goes on. All promoted by the endless parade of interchangeable, nameless faceless talentless hacks and disingenuous grifters who only care about making &ldquo;BIG LINE GO UP&rdquo;.</p>
<p>It&rsquo;s all so tiring, so perhaps you can understand why I&rsquo;d immediately recoil in disgust and go my own way with an alternative I can control.</p>
<p>Or, to put it in even simpler terms:</p>
<p><img
  src="https://wilhelm.codes/blog/my-blog-publishing-setup/image-1_hu_dbfacd982ca699f4.webp"
  srcset="/blog/my-blog-publishing-setup/image-1_hu_dbfacd982ca699f4.webp 598w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="598"
  height="369"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="the-stack">
  <a class="heading-link" href="#the-stack">The &ldquo;Stack&rdquo;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I suppose it&rsquo;s technically a &ldquo;stack&rdquo;, but it seems a bit funny to use the term when referencing a blog setup. Just as with my <a href="https://plantsm.art"><span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span></a> project, the goal is to automate as much as possible while keeping maintenance requirements and hard- and soft-dollar costs as low as possible. For this, I need only use a handful of tools to keep this space operational.</p>
<p>As far as the question over &ldquo;cost&rdquo;, outside of time spent, it&rsquo;s about ~$100 AUD per year for the domain name. This blog is a very small and inconsequential part of this domain, so the overall cost is absorbed by &ldquo;other stuff&rdquo;.</p>
<p>So, the greatest cost is&hellip; time? I guess 🤷</p>
<h3 id="obsidian">
  <a class="heading-link" href="#obsidian">Obsidian<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>While I won&rsquo;t be going into what <a href="">Obsidian</a> is and how it works, I will say it&rsquo;s been a personal boon in how I keep notes and track of ideas. I have a lot of fleeting / ephemeral thoughts I that would otherwise lose instantly if I didn&rsquo;t immediately jot them down using this app.</p>
<p>So, it makes perfect sense for me to use my personal <a href="https://help.obsidian.md/Getting+started/Create+a+vault">vault</a> to store all my blog content. The fact Obsidian content nothing more than Markdown files makes working with them using other bits of tech that much easier.</p>
<p>Oh, it&rsquo;s free btw.</p>
<h3 id="hugo">
  <a class="heading-link" href="#hugo">Hugo<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p><a href="https://gohugo.io/">This</a> is probably one of the more popular options when it comes to static site generators. It&rsquo;s easy to install, is incredibly extensible and has loads of documentation to help you along. The template syntax sits upon Golang&rsquo;s templating engine, so if you have a strong background as a Go developer like myself, you&rsquo;ll be right at home.</p>
<p>However, knowing Go isn&rsquo;t a hard requirement though it will make getting on top of things quite a bit easier.</p>
<p>What I enjoyed while learning about this project was I got to control every aspect of how I wanted my blog to look and function. Building the current design from the ground up with Hugo was incredibly fun.</p>
<p>Also, free. Very cool.</p>
<h3 id="cloudflare">
  <a class="heading-link" href="#cloudflare">Cloudflare<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I already used Cloudflare to manage this domain&rsquo;s DNS settings, so it only made sense to publish my static site to Cloudflare Pages over, say, Vercel or Github Pages. There&rsquo;s nothing spectacular going on here outside of me just wanting to keep these two things in the same place.</p>
<p>That being said, I have no issues with migrating else where if the need should ever arise. Though, I doubt this blog will hit the 20k file limit of Cloudflare Pages any time soon.</p>
<p>Again, free for the purposes of this article. If you don&rsquo;t have a dedicated personal domain, you will be provided a subdomain attached to the <code>pages.dev</code> apex.</p>
<h2 id="the-good-stuff">
  <a class="heading-link" href="#the-good-stuff">The Good Stuff<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>How is this all put together for day-to-day usage? Luckily, the Cloudflare stuff runs on auto-pilot, so it&rsquo;s pretty much set-and-forget. I suppose you could say the same about the other components, but they&rsquo;re the parts I touch the most.</p>
<p>I have a single vault in Obsidian. It&rsquo;s where all my thoughts go. Stored within is a top-level <code>Blog/</code> directory. Care to hazard a guess as to what it may contain?</p>
<p><img
  src="https://wilhelm.codes/blog/my-blog-publishing-setup/image-2_hu_84ed8786abb466c3.webp"
  srcset="/blog/my-blog-publishing-setup/image-2_hu_84ed8786abb466c3.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="928"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Hugo has the concept of <a href="https://gohugo.io/content-management/page-bundles/">page bundles</a> where you can group all resources associated with your blog articles within a single directory. You&rsquo;ll notice above all directories mirror the blog posts hosted on this site. In my case, we can consider the directory names to be the human-friendly slugs you see in your address bar that point to the associated content. Within these directories, you&rsquo;d see any other resource I may link to; source code, images, etc&hellip; The best thing about this is I only have to perform a relative reference to these resources. If I want to link to <code>image-1.png</code>, I only have to reference it as <code>![](/blog/my-blog-publishing-setup/image-1.png)</code> without worrying about specifying an absolute path; very nice.</p>
<p>When I want to write a new article, I first create a new directory here. I may already have the title of the article in mind, but it can be in flux until I decide to publish it. In the new directory, I create a file called <code>index.md</code>. This file contains all the content of the associated article written using Markdown.</p>
<p>I then use an Obsidian <a href="https://help.obsidian.md/Plugins/Templates">template</a> dedicated to new blog posts and apply it to the new file. This ensures I have all <a href="https://jekyllrb.com/docs/front-matter/">front matter</a> properties ready to go. This makes configuring my posts in the editor that much easier.</p>
<p><img
  src="https://wilhelm.codes/blog/my-blog-publishing-setup/image-3_hu_7267be29c468e715.webp"
  srcset="/blog/my-blog-publishing-setup/image-3_hu_7267be29c468e715.webp 736w, /blog/my-blog-publishing-setup/image-3_hu_1ad568a578b3cf1f.webp 1104w, /blog/my-blog-publishing-setup/image-3_hu_3acce599e1729f38.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="478"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>All that&rsquo;s left is writing my articles! Well, not really. I need a way to synchronise these files with my local Hugo instance. As you can see <a href="https://github.com/wilhelm-murdoch/wilhelm.codes/tree/main/content/blog">here</a>, the content mirrors what I have stored in my Obsidian vault.</p>
<p>This can be easily addressed with 2 small apps:</p>
<ol>
<li><code>fswatch</code> which monitors a specified directory for changes and allows you to trigger subsequent commands for specific file system events. Basically, I want to watch for any changes in my Obsidian vaults <code>Blog/</code> directory.</li>
<li><code>rsync</code> to dynamically keep the target Hugo directory in sync with the Obsidian source directory.</li>
</ol>
<p>I, of course, have this placed in a script that I have running in the background while I write. This is a great help during the drafting phase as I have Hugo dynamically processing and displaying these changes locally. This allows me to easily proof my work visually as I carry on writing.</p>
<p>Here&rsquo;s the full script:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">SOURCE</span><span class="o">=</span><span class="si">${</span><span class="nv">SOURCE</span><span class="p">:=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> SOURCE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">DESTINATION</span><span class="o">=</span><span class="si">${</span><span class="nv">DESTINATION</span><span class="p">:=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">2</span><span class="si">}</span><span class="s2">&#34;</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> DESTINATION
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">fswatch -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SOURCE</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r event<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl">    rsync -av --delete <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SOURCE</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">DESTINATION</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>I keep this running whenever I&rsquo;m writing a new article. Obsidian saves in almost realtime, so as I type the changes almost immediately sync across to the local Hugo server so I can review my changes.</p>
<p>You effectively run it as:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ ./sync.sh /path/to/obsidian/blog/ /path/to/hugo/content/</span></span></code></pre></div>
</div>
<p>For <code>rsync</code> to work as intended and keep the destination directory completely in sync with the source directory, remember to add a trailing slash to both directory arguments.</p>
<h3 id="no-auto-commit">
  <a class="heading-link" href="#no-auto-commit">No Auto-Commit?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Nope. Not here. There are a few other peeps with blogs out there that &ldquo;commit on change&rdquo;, but I prefer to have a bit more control over what actually gets published. For that, I simply use plain-old git commands to publish my new article:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ git add -A
</span></span><span class="line"><span class="cl">$ git commit -m <span class="s1">&#39;some banal new bullshit screed&#39;</span>
</span></span><span class="line"><span class="cl">$ git push origin main</span></span></code></pre></div>
</div>
<p>Cloudflare monitors the associated repository, picks up the change and the auto-magic-ally builds and pushes everything to the public eye.</p>
<p>With this flow, I can see my changes locally in near realtime and push to &ldquo;production&rdquo; only when I&rsquo;m happy with the results.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In Closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I&rsquo;m stoked to have my very own place again that I have complete control over. I&rsquo;ve thoroughly enjoyed creating this new blog design and I may even open-source it one day.</p>
<p>That said, I&rsquo;ve made a very loose commitment to post a new article every week this year of our lord 2025 and I&rsquo;m hoping this will make things heaps simpler. I only want to worry about what I&rsquo;m going to write and just get the content out there.</p>
<p>I sincerely hope I can keep this self-imposed weekly commitment. 😬👍</p>]]></content:encoded></item></channel></rss>