<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wilhelm Murdoch</title>
    <description>The latest articles on DEV Community by Wilhelm Murdoch (@wilhelm-codes).</description>
    <link>https://dev.to/wilhelm-codes</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4034067%2Ff94aa74b-4be5-4daf-9dec-797c13815677.jpg</url>
      <title>DEV Community: Wilhelm Murdoch</title>
      <link>https://dev.to/wilhelm-codes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wilhelm-codes"/>
    <language>en</language>
    <item>
      <title>I Didn't Need a DAG. I Needed Cron.</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/i-didnt-need-a-dag-i-needed-cron-1bb0</link>
      <guid>https://dev.to/wilhelm-codes/i-didnt-need-a-dag-i-needed-cron-1bb0</guid>
      <description>&lt;p&gt;Hey, nerds! Stop me if you've heard this one before.&lt;/p&gt;

&lt;p&gt;Every homelab eventually hits the same wall. You have a pile of guests, a pile of small chores those guests need done on a schedule and absolutely no desire to SSH into each one by hand. Disk usage, uptime checks, arbitrary commands, pending updates, failed systemd units, the list goes on. This is all boring stuff that quietly rots away if nobody looks at it.&lt;/p&gt;

&lt;p&gt;So I went shopping for an automation platform. I came back with nothing and then built one out of SSH, bash, cron and other bits and bobs already running in the homelab. It's called &lt;strong&gt;NetRunner&lt;/strong&gt;, because I'm physically incapable of naming anything in this network without a cyberpunk reference.&lt;/p&gt;

&lt;p&gt;Before we begin, I should take a moment to define what a &lt;em&gt;DAG&lt;/em&gt; actually is. A &lt;strong&gt;D&lt;/strong&gt;irected &lt;strong&gt;A&lt;/strong&gt;cyclic &lt;strong&gt;G&lt;/strong&gt;raph is a fancy name for a to-do list with dependencies. Each node in the graph is a task and each arrow says "this one waits for that one". &lt;em&gt;Directed&lt;/em&gt; means the arrows only point one way. &lt;em&gt;Acyclic&lt;/em&gt; means you can never follow them back to where you started, so you can't accidentally shoot yourself in the foot with a loop of tasks all waiting on each other forever.&lt;/p&gt;

&lt;p&gt;The order comes from the arrows, not the order you wrote things down in. Say you take a backup, then verify it and copy it offsite, then clean up. Verify and copy both wait on the backup but not on each other, so they run side by side. The clean-up waits for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's go shopping!
&lt;/h2&gt;

&lt;p&gt;I gave the usual suspects a fair go. &lt;a href="https://kestra.io" rel="noopener noreferrer"&gt;Kestra&lt;/a&gt;, &lt;a href="https://www.windmill.dev" rel="noopener noreferrer"&gt;Windmill&lt;/a&gt; and &lt;a href="https://semaphoreui.com" rel="noopener noreferrer"&gt;Semaphore&lt;/a&gt; are all genuinely good software built by people who clearly know what they're doing. They're also built for teams. Teams that need a web UI, a worker model, RBAC and the plugin ecosystems. I'm just a weird little guy with a Proxmox cluster and a mild addiction to YAML.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dagu.cloud" rel="noopener noreferrer"&gt;Dagu&lt;/a&gt; came the closest. It's a single binary written in my &lt;a href="https://go.dev/" rel="noopener noreferrer"&gt;favourite language&lt;/a&gt;, has jobs defined as YAML and no database to babysit. I liked it a lot and I &lt;em&gt;really&lt;/em&gt; tried to force myself to use it. Then I sat down to write my first workflow and realised that not a single one of my jobs was actually a graph. They were all "run this thing on those boxes over yonder and read me the riot act if something failed". &lt;/p&gt;

&lt;p&gt;Then, as I usually do in these moments, I took a step away from the desk and had a think. What is the bare minimum required to get this job done well? &lt;/p&gt;

&lt;p&gt;The obvious answer was every piece of a central control node was already sitting in the network. Terraform already provisions the guests. Ansible already configures them. Forgejo already holds the code. Proxmox already knows every guest, its tags, its address AND comes packaged with an API. Apprise was also running in the homelab as a notification pipeline to a dedicated set of Slack channels. The only thing missing was a little bit of glue and a box to sit in the middle to press the button.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Boring almost always beats clever.&lt;/p&gt;

&lt;p&gt;— Evergreen.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Big brain time!
&lt;/h2&gt;

&lt;p&gt;NetRunner is one unprivileged LXC container with one core and a gig of memory. It has no web UI, no database and, with the exception of SSH, no open ports. It doesn't need Python or Ansible to run because the whole runtime is Bash, OpenSSH, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;, &lt;code&gt;flock&lt;/code&gt; and &lt;code&gt;timeout&lt;/code&gt;. If you've administered a Linux box at any point in the last thirty years, you already know how every part of it works. &lt;/p&gt;

&lt;p&gt;Here's an obligatory Mermaid chart with the basic workflow. We'll get to what &lt;code&gt;nr&lt;/code&gt; is in a bit.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  forge[Forgejo: jobs as code] --&amp;gt; ansible[Ansible playbook]
  ansible --&amp;gt; cron
  subgraph controller[NetRunner container]
    cron[cron] --&amp;gt; job[nr job run]
    job --&amp;gt; exec[nr exec]
  end
  pve[Proxmox API] --&amp;gt; exec
  exec -- "SSH, script on stdin" --&amp;gt; guests[LXC containers and VMs]
  job -- "on failure" --&amp;gt; slack[apprise to Slack]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The self-imposed design constraints were as follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A job is a script that runs on the controller and &lt;em&gt;only&lt;/em&gt; on the controller.&lt;/li&gt;
&lt;li&gt;The controller sends commands to the guests over SSH, collects their output and makes its decisions locally.&lt;/li&gt;
&lt;li&gt;The guests run no scheduler, no agent and no daemon. They keep no copy of any job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters because the homelab runs on an immutable infrastructure policy, so a guest should look the same after a job as it did before. NetRunner pipes each command or script into the guest on standard input and the guest runs it with &lt;code&gt;bash -s&lt;/code&gt;. Nothing lands on the guest's disk, so nothing needs cleaning up afterwards.&lt;/p&gt;

&lt;p&gt;Obviously, there could be exceptions to that rule, but if I aim to write something ephemeral to a guest's disk, it goes in &lt;code&gt;/tmp&lt;/code&gt;. If I need to write something permanent, I codify it in Ansible or Terraform. It's more of a guideline than a hard rule. &lt;/p&gt;

&lt;h2&gt;
  
  
  One key to rule them all... with great care.
&lt;/h2&gt;

&lt;p&gt;Each guest gets a dedicated &lt;code&gt;netrunner&lt;/code&gt; user. The LXC containers get it from a Proxmox hookscript on every start, which Terraform manages with the &lt;a href="https://registry.terraform.io/providers/bpg/proxmox/latest" rel="noopener noreferrer"&gt;proxmox&lt;/a&gt; provider. New VMs get it from cloud-init on first boot. &lt;/p&gt;

&lt;p&gt;That user's &lt;code&gt;authorized_keys&lt;/code&gt; holds exactly one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from="10.0.0.xx",no-agent-forwarding,no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA... netrunner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is an &lt;code&gt;ED25519&lt;/code&gt; pair that Terraform generates. The private half lives on the controller and nowhere else. The &lt;code&gt;from=&lt;/code&gt; option is doing the real work here. Steal that private key, copy it to your laptop and try it from anywhere other than the controller's static address and every guest will politely tell you to get fucked. The key is only useful from one IP on the network. &lt;/p&gt;

&lt;p&gt;I'm not going to pretend this is bulletproof, because it isn't. The &lt;code&gt;netrunner&lt;/code&gt; user has passwordless sudo, since most of the chores I care about need root. That means the controller is effectively root on every guest. Own the container and you own the lab. It's the same trade every configuration management tool makes and I've made it with my eyes open. &lt;/p&gt;

&lt;p&gt;The container is small, hardened, exposes nothing but SSH and has one job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the guests.
&lt;/h2&gt;

&lt;p&gt;Hard-coding an inventory is how you end up running jobs against a guest you decommissioned weeks ago. So, to save me the trouble, every five minutes, &lt;code&gt;nr refresh&lt;/code&gt; asks the Proxmox API for the running guests and caches the name, VM ID, node, type, tags and address of each one on disk.&lt;/p&gt;

&lt;p&gt;The fun bit is that when you execute a job it never names a host. It selects guests with filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; music &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;uptime
&lt;/span&gt;nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; lxc &lt;span class="nt"&gt;-P&lt;/span&gt; 8 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'apt list --upgradable 2&amp;gt;/dev/null | tail -n +2 | wc -l'&lt;/span&gt;
nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; web &lt;span class="nt"&gt;-a&lt;/span&gt; dns &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;THRESHOLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;70 &lt;span class="nt"&gt;-s&lt;/span&gt; filesystems.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filter by Proxmox tag, by guest type or by a regex on the name. The tags come from Terraform. So when I tag a guest in its Terraform stack, I'm also quietly subscribing it to every job that selects that tag. A new guest needs zero changes on the controller. The hookscript or cloud-init gives it the account, the next refresh picks it up and it starts getting nagged on schedule like everybody else. This is the type of set-and-forget project I love to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;nr&lt;/code&gt; helper.
&lt;/h2&gt;

&lt;p&gt;Everything on the controller goes through one command: &lt;code&gt;nr&lt;/code&gt;. It's a bash CLI built with &lt;a href="https://bashly.dev" rel="noopener noreferrer"&gt;Bashly&lt;/a&gt;, which takes a YAML description of your commands, flags and help text and spits out a single, surprisingly well-designed bash script. If you've been reading this blog for a while ( you haven't ) you'd know that &lt;a href="https://wilhelm.codes/blog/filtering-docker-containers-with-jq/" rel="noopener noreferrer"&gt;I've&lt;/a&gt; &lt;a href="https://wilhelm.codes/blog/liberating-custom-slack-emojis/" rel="noopener noreferrer"&gt;been&lt;/a&gt; &lt;a href="https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/" rel="noopener noreferrer"&gt;using&lt;/a&gt; this utility for &lt;em&gt;years&lt;/em&gt;. &lt;/p&gt;

&lt;p&gt;Yes, there's YAML in the bash. There's always YAML. I've made my peace with it and so should you.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nr exec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs a command or a script on every matching guest and collects the results.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nr guests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lists the cached guests, with the same filters.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nr tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lists the Proxmox tags and the guests behind each one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nr ssh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opens a session to one guest by name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nr notify&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sends a notification through apprise.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nr job run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs one job, exactly as cron would.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nr refresh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rebuilds the guest cache and prunes old runs.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;nr exec&lt;/code&gt; is the workhorse. It fans out to four guests at a time by default and writes &lt;code&gt;&amp;lt;guest&amp;gt;.out&lt;/code&gt;, &lt;code&gt;&amp;lt;guest&amp;gt;.err&lt;/code&gt; and &lt;code&gt;&amp;lt;guest&amp;gt;.rc&lt;/code&gt; for each one, plus a &lt;code&gt;summary.json&lt;/code&gt; that ties them together. A job script is then just ordinary bash and &lt;code&gt;jq&lt;/code&gt; over a directory of files. No SDK, no bespoke expression language and definitely no plugin API I have to learn and quickly forget.&lt;/p&gt;

&lt;p&gt;Secrets are covered as best I can for this kind of setup. &lt;code&gt;nr exec --pass SECRET_SQUIRREL_TOKEN&lt;/code&gt; writes the variable into the stdin stream as an &lt;code&gt;export&lt;/code&gt; line. This means a passed secret never shows up on a command line on the guest or its shell history.&lt;/p&gt;

&lt;h3&gt;
  
  
  It's also just a nice way to poke at the lab.
&lt;/h3&gt;

&lt;p&gt;The scheduled jobs are the reason NetRunner exists, but the thing I didn't expect is how often I now reach for &lt;code&gt;nr&lt;/code&gt; from a shell with no job in sight. The controller already knows every guest, so it's become the place I go when I have a question about the fleet and can't be bothered remembering an IP.&lt;/p&gt;

&lt;p&gt;Some things I've typed within minutes of finally getting this running, more or less verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Which guests are running and what are they tagged with?&lt;/span&gt;
nr tags

&lt;span class="c"&gt;# Kernel versions across every LXC processing eight at a time.&lt;/span&gt;
nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; lxc &lt;span class="nt"&gt;-P&lt;/span&gt; 8 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;

&lt;span class="c"&gt;# Which VMs have a reboot pending?&lt;/span&gt;
nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; qemu &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'test -f /var/run/reboot-required &amp;amp;&amp;amp; echo yes || echo no'&lt;/span&gt;

&lt;span class="c"&gt;# Is docker happy on the media guests? Skip the music server.&lt;/span&gt;
nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; media &lt;span class="nt"&gt;-x&lt;/span&gt; navidrome &lt;span class="nt"&gt;--&lt;/span&gt; systemctl is-active docker

&lt;span class="c"&gt;# Just get me a shell on that thing.&lt;/span&gt;
nr ssh koito
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Obviously, I can easily eyeball a few of these from the Proxmox web UI, but we're not in a browser are we?&lt;/p&gt;

&lt;p&gt;Every output line is prefixed with the guest name and each one is displayed as a single block, so a fleet-wide &lt;code&gt;df -h&lt;/code&gt; is readable instead of a wall of text. At the end you get a list of anything that failed and the path to the results directory in case you want to go digging.&lt;/p&gt;

&lt;p&gt;All results are stored locally on the controller as plain'ole files. This means composing stuff with the rest of the toolbox is fairly trivial. &lt;code&gt;--json&lt;/code&gt; writes the &lt;code&gt;summary.json&lt;/code&gt; to standard output and &lt;code&gt;--quiet&lt;/code&gt; suppresses the guest output when you only care about the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Name every VM guest where the command failed.&lt;/span&gt;
nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; qemu &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;hostname&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.rc != 0) | .name'&lt;/span&gt;

&lt;span class="c"&gt;# Collect every os-release into a known directory, then read one.&lt;/span&gt;
nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/os &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'cat /etc/os-release'&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/os/koito.out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because &lt;code&gt;nr notify&lt;/code&gt; reads standard input, a one-off report is a pipe away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; music &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;uptime&lt;/span&gt; | nr notify &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="s2"&gt;"Uptime of the music guests"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here it is in the &lt;code&gt;#netrunner&lt;/code&gt; channel moments later:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxt0zxs8owkh47pjphgj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxt0zxs8owkh47pjphgj.png" alt="A preview of the resulting Slack notification." width="800" height="278"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A preview of the resulting Slack notification.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Those ad-hoc runs land in &lt;code&gt;/var/lib/netrunner/runs/adhoc/&lt;/code&gt; and get pruned after a fortnight, same as everything else. This is also exactly how I test a new job before it gets a schedule. Run the commands by hand until they do the right thing, then move them into a script and add the YAML. There's no "run locally" mode to emulate because the shell &lt;em&gt;is&lt;/em&gt; the local mode.&lt;/p&gt;
&lt;h2&gt;
  
  
  Jobs are code. Obviously.
&lt;/h2&gt;

&lt;p&gt;Every job lives in the forge next to everything else. A job is two things: a script in &lt;code&gt;jobs/bin/&lt;/code&gt; and an entry in &lt;code&gt;jobs/jobs.yaml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;disk-usage&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fail when a filesystem on a guest is at or above 85%.&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;disk-usage&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;
  &lt;span class="na"&gt;exclude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;truenas&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info:&lt;/strong&gt; You might notice I'm excluding TrueNAS here. It's not a standard guest, but a full-blown dedicated distribution that manages itself well enough. No need for my grubby scripts to taint all my legally-acquired ISOs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The job script for something like "tell me which guests have failed systemd units" is about twenty lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NR_RUN_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/units"&lt;/span&gt;

&lt;span class="c"&gt;# nr exec exits 1 when a guest fails. The script reads each result itself.&lt;/span&gt;
nr &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-P&lt;/span&gt; 8 &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;results&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'systemctl --failed --no-legend --plain | wc -l'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; name rc out&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"UNREACHABLE &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAILED UNITS &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
  &lt;span class="k"&gt;fi
done&lt;/span&gt; &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | [.name, (.rc // "skip" | tostring), .out] | @tsv'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;results&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/summary.json"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't judge me, all Bash scripting looks ugly. Anyway, as is standard in Linux, you print whatever you want and exit non-zero when something's wrong. That's effectively the API in its entirety.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ansible does the boring part.
&lt;/h2&gt;

&lt;p&gt;I never touch the controller by hand. The playbook builds &lt;code&gt;nr&lt;/code&gt; with Bashly, checks the result with &lt;code&gt;bash -n&lt;/code&gt; before it swaps the old binary out and then reads &lt;code&gt;jobs.yaml&lt;/code&gt;. It validates every job before it changes anything through an assertion and refuses to continue if any aspect of a job fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt; &lt;span class="s"&gt;... more yaml up here ...&lt;/span&gt;
  &lt;span class="s"&gt;tasks&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assert each job is valid&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.assert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;that&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;item.name is defined and item.name is match('^[a-z0-9][a-z0-9-]*$')&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;item.schedule is defined and (item.schedule | split | length) == &lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;item.script is defined&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;(netrunner_jobs_src ~ '/bin/' ~ item.script) is file&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;(item.env | default({}) | dict2items | map(attribute='key') | reject('match', '^[A-Za-z_][A-Za-z0-9_]*$') | list | length) == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;item.notify | default('failure') in ['failure', 'always', 'never']&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;item.exclude | default([]) is sequence and item.exclude | default([]) is not string&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;(item.exclude | default([]) | reject('match', '^[A-Za-z0-9][A-Za-z0-9.-]*$') | list | length) == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="na"&gt;fail_msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Job&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;default('(no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;name)')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;jobs/jobs.yaml&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;valid.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;See&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;field&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;list&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;at&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;top&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file."&lt;/span&gt;
        &lt;span class="na"&gt;quiet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;jobs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
      &lt;span class="na"&gt;loop_control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;default('(no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;name)')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;span class="err"&gt;  &lt;/span&gt;&lt;span class="s"&gt;... more yaml down here ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll notice &lt;code&gt;{{ jobs }}&lt;/code&gt; near the bottom. That's a variable defined by yet another YAML file containing a list of jobs formatted like the example at the start of prior section.&lt;/p&gt;

&lt;p&gt;If the jobs pass, it writes two files on the controller. The first is &lt;code&gt;jobs.json&lt;/code&gt;, with secrets resolved from my password manager at deploy time. The second is &lt;code&gt;/etc/cron.d/netrunner&lt;/code&gt;, with one line per enabled job. Each line runs &lt;code&gt;nr job run &amp;lt;name&amp;gt;&lt;/code&gt; as the &lt;code&gt;netrunner&lt;/code&gt; user.&lt;/p&gt;

&lt;p&gt;It's a stupid-simple template using that same &lt;code&gt;{{ jobs }}&lt;/code&gt; variable passed through. This is what we use to create the resulting cron table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;SHELL=/bin/bash
PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Keep the guest cache fresh for the helpers and the jobs.
*/5 * * * * &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;netrunner_user&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; systemd-cat -t netrunner-refresh /usr/local/bin/nr refresh --quiet
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;job&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;job.enabled&lt;/span&gt; &lt;span class="o"&gt;| &lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;| &lt;/span&gt;&lt;span class="nf"&gt;bool&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;

# &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;job.name&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;: &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;job.description&lt;/span&gt; &lt;span class="o"&gt;| &lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'no description'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;job.schedule&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;netrunner_user&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; systemd-cat -t netrunner-&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;job.name&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; /usr/local/bin/nr job run &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;job.name&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endfor&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the entire workflow for a new automation is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write a script.&lt;/li&gt;
&lt;li&gt;Add a few lines of YAML.&lt;/li&gt;
&lt;li&gt;Push to the forge.&lt;/li&gt;
&lt;li&gt;Run the playbook.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Want to pause a job? Set &lt;code&gt;enabled: false&lt;/code&gt; and run the playbook. Want to drop a job? Delete it and run the playbook, which also removes the script from the container. If I want to see who fat-fingered the wrong thing, I have the git log to stare at. &lt;code&gt;git blame&lt;/code&gt; tells me exactly who broke the thing aaaaand, oh wait, that's right, it was me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cron, but with a seatbelt.
&lt;/h2&gt;

&lt;p&gt;Raw cron has two classic ways to ruin your weekend. Jobs pile up on top of each other when one runs long and jobs hang forever when a guest goes quiet. &lt;code&gt;nr job run&lt;/code&gt; handles both with tools that ship with every Linux box on the planet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;flock&lt;/code&gt; makes sure a second run of a job won't start while the first one is still going. &lt;code&gt;timeout&lt;/code&gt; kills a run once it goes over its budget, which defaults to an hour. Each run gets its own directory under &lt;code&gt;/var/lib/netrunner/runs/&lt;/code&gt; with its log, its exit code and the raw results from every guest. Output goes to the journal tagged &lt;code&gt;netrunner-&amp;lt;name&amp;gt;&lt;/code&gt;, so reading a job's history is just &lt;code&gt;journalctl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-t&lt;/span&gt; netrunner-disk-usage &lt;span class="nt"&gt;--since&lt;/span&gt; today
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again, all tools that have existed forever, with the addition of things that already do real work in the homelab.&lt;/p&gt;

&lt;p&gt;When a job fails, &lt;code&gt;nr&lt;/code&gt; grabs the last forty lines of output and posts them to apprise, which drops them into a &lt;code&gt;#netrunner&lt;/code&gt; Slack channel. That's the entirety of the alerting stack and it's currently more than adequate for my needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  There actually are some ugly bits to this.
&lt;/h2&gt;

&lt;p&gt;Here's what NetRunner doesn't do, in the interest of not pretending otherwise and I'm almost certain it's the part that most everyone reading this has been looking forward to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There's no failover.&lt;/strong&gt; A single LXC means a single controller. If it dies, the jobs stop and the guests carry on regardless. The container has no backup either, because it doesn't need one. The jobs live in the forge, the key lives in Terraform state and one &lt;code&gt;terraform apply&lt;/code&gt; plus one playbook run rebuilds the whole thing. If it goes down for whatever reason, I have a reachability check from &lt;a href="https://uptime.kuma.pet/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; that'll alert me in Slack as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't verify host keys.&lt;/strong&gt; Guests get recreated and DHCP addresses tend to wander, so strict host key checking would mostly produce noise. The cost is that a hostile box on the LAN that claims a guest's address could receive a job, secrets and all. It doesn't even need the key. The controller is the one doing the authenticating, not the guest. I accept that on my own network. I wouldn't anywhere else. I sure as shit wouldn't at work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cache can be five minutes stale.&lt;/strong&gt; If an address moves between guests inside that window, a job can land on the wrong box. It's a small window and it's documented. I'd get an alert for this as well, so no biggie. Well, it could potentially be a very BIG biggie depending on what I'm running...&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's no UI.&lt;/strong&gt; I consider this a feature, but I appreciate not everyone will.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these would survive a production review at work. That's fine. This isn't work, and every one of these trade-offs is written down in the homelab's wiki next to the reason I made it.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing ...
&lt;/h2&gt;

&lt;p&gt;The platforms I looked at are not bad software. They solve problems that I simply don't have: many users, complex dependency graphs, audit requirements and a web UI for people who don't live in a terminal. I have a handful of guests and a list of chores.&lt;/p&gt;

&lt;p&gt;NetRunner is some well structured bash, one SSH key, one cron file and a playbook. There's nothing to upgrade except bash and OpenSSH, and those were never going anywhere. Every part of it is something I already knew how to debug at two in the morning, which turns out to be the only feature that really matters. &lt;/p&gt;

&lt;p&gt;I guess the whole point of this post is you don't always need to resort to using a super shiny off-the-shelf solution when you'll only use 5% of its functionality. Especially, when you already have everything you need, but the glue. The rule of &lt;a href="https://martinfowler.com/bliki/Yagni.html" rel="noopener noreferrer"&gt;YAGNI&lt;/a&gt; ( &lt;strong&gt;Y&lt;/strong&gt;ou &lt;strong&gt;A&lt;/strong&gt;ren't &lt;strong&gt;G&lt;/strong&gt;onna &lt;strong&gt;N&lt;/strong&gt;eed &lt;strong&gt;I&lt;/strong&gt;t ) applies and prevails. At any rate, this was a fun weekend project!&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;nr&lt;/code&gt; code is unique to my homelab and I don't plan on publishing it as it literally will not run anywhere else, but I'm flexible. If there's genuine interest, I'll post the source as well as the playbook so you can tinker with it youself.&lt;/p&gt;

&lt;p&gt;Wake up, samurai. We have guests to patch.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>bash</category>
      <category>ansible</category>
      <category>automation</category>
    </item>
    <item>
      <title>The Safest Login Page is the One I Never Published</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/the-safest-login-page-is-the-one-i-never-published-oeg</link>
      <guid>https://dev.to/wilhelm-codes/the-safest-login-page-is-the-one-i-never-published-oeg</guid>
      <description>&lt;p&gt;I finally got tired of not knowing whether anyone reads this thing, so I spent a Saturday afternoon standing up &lt;a href="https://umami.is" rel="noopener noreferrer"&gt;Umami&lt;/a&gt; in the homelab. The install was the easy part; the instructions are dead simple.&lt;/p&gt;

&lt;p&gt;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't good enough for &lt;em&gt;me&lt;/em&gt;. So, I did what I normally do and stubbornly wandered off to my own path.&lt;/p&gt;

&lt;p&gt;Quick bit of context for anyone who hasn'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'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's Proxmox cluster.&lt;/p&gt;

&lt;p&gt;I landed on it for the "normal" reasons. Cloudflare's Analytics are free but limited. They don't give me precisely what I'm looking for and they're a bit too slow for my liking. And, I'm not even going to give Google a single thought; they have enough of my data already.&lt;/p&gt;

&lt;p&gt;Which leaves the question: how does a script tag on a public website safely and &lt;em&gt;securely&lt;/em&gt; reach a container in my house without punching a hole in my router?&lt;/p&gt;

&lt;h2&gt;
  
  
  The answer everyone gives you.
&lt;/h2&gt;

&lt;p&gt;The standard answer is a &lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/" rel="noopener noreferrer"&gt;Cloudflare Tunnel&lt;/a&gt;. A small daemon, &lt;code&gt;cloudflared&lt;/code&gt;, runs next to your service and dials &lt;em&gt;out&lt;/em&gt; 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's genuinely lovely and it costs nothing. And since I already use Cloudflare to &lt;em&gt;currently&lt;/em&gt; host my static websites and domains it was just the pragmatic option. &lt;/p&gt;

&lt;p&gt;I think it'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.&lt;/p&gt;

&lt;p&gt;Anyway, Umami has an admin interface. An admin interface typically has a login page. So, most guides you'll find after searching around will tell you to put something like &lt;a href="https://developers.cloudflare.com/cloudflare-one/policies/access/" rel="noopener noreferrer"&gt;Cloudflare Access&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;I was about to wire it to my IDP when I actually stopped to read my own ingress config a bit more closely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tunnels match on path.
&lt;/h2&gt;

&lt;p&gt;The important little detail I almost skimmed past was that a tunnel's ingress rules do not just match on hostname, but on &lt;em&gt;path&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I had written this, which is what everyone starts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"analytics.nightcity.network"&lt;/span&gt;
    &lt;span class="nx"&gt;service&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://umami:3000"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http_status:404"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You write a single hostname and everything on it goes straight to the app while anything else gets a &lt;code&gt;404&lt;/code&gt;. Ok.&lt;/p&gt;

&lt;p&gt;But my analytics service only &lt;em&gt;needs&lt;/em&gt; to expose &lt;em&gt;two things&lt;/em&gt; to the public internet; the tracker script and the collect endpoint. Nobody on the internet needs to see &lt;code&gt;/login&lt;/code&gt; 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?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"analytics.nightcity.network"&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"^/bundle&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.js$"&lt;/span&gt;
    &lt;span class="nx"&gt;service&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://nginx:80"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"analytics.nightcity.network"&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"^/api/v1/data$"&lt;/span&gt;
    &lt;span class="nx"&gt;service&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://nginx:80"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http_status:404"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I only need precisely &lt;em&gt;two&lt;/em&gt; paths out while everything else gets met with a lovely &lt;code&gt;404&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;Drawn out, the whole arrangement is two seperate paths that happen to share a name:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    N["analytics.nightcity.network"]
    N --&amp;gt;|asked from the internet| CD[Cloudflare DNS]
    N --&amp;gt;|asked from my LAN| TD[Technitium]
    CD --&amp;gt;|104.21.x.x| E[Cloudflare edge]
    TD --&amp;gt;|10.0.0.200| P[My reverse proxy]
    E --&amp;gt; C[cloudflared]
    C --&amp;gt;|/bundle.js| X[nginx]
    C --&amp;gt;|/api/v1/data| X
    C --&amp;gt;|everything else| F[404]
    X --&amp;gt; U[Umami]
    P --&amp;gt; U&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;code&gt;cloudflared&lt;/code&gt; decides what gets through and it'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'll explain a bit further down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I think this is the better trade.
&lt;/h2&gt;

&lt;p&gt;I want to be fair to Access here, because it's a great product and there are plenty of setups where it's the right call. If you genuinely need to reach an admin panel from anywhere on a machine you don't control, without a VPN, Access is exactly the kind of tool that'll get you there.&lt;/p&gt;

&lt;p&gt;But if you don't need that, consider what you're actually buying in to.&lt;/p&gt;

&lt;p&gt;With Access, &lt;code&gt;/login&lt;/code&gt; is on the internet. It returns a challenge instead of a form, which is much better than nothing, but the &lt;em&gt;attack surface&lt;/em&gt; is still there. There's an auth flow to configure, an IDP to keep working and an additional sign-in every time. Umami doesn't consume the Access token, so you authenticate to Cloudflare and then authenticate again to Umami. Nobody has time for that nonsense.&lt;/p&gt;

&lt;p&gt;With path scoping, &lt;code&gt;/login&lt;/code&gt; returns a &lt;code&gt;404&lt;/code&gt;. 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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Elimination is the ultimate form of hardening.&lt;/p&gt;

&lt;p&gt;— Pretty much every cybersecurity professional.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The thing I keep coming back to: &lt;strong&gt;an attack surface you removed cannot be misconfigured later.&lt;/strong&gt; A control you added can. This is all about shrinking the &lt;a href="https://en.wikipedia.org/wiki/Attack_surface" rel="noopener noreferrer"&gt;attack surface&lt;/a&gt;, which is an idea that's far older than me. We'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't necessarily mean appropriate for something like this.&lt;/p&gt;

&lt;p&gt;Worth being precise about which of these is which, though. The &lt;code&gt;404&lt;/code&gt; on &lt;code&gt;/login&lt;/code&gt; 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'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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it work though?
&lt;/h2&gt;

&lt;p&gt;The nice part about this setup is that you can test the public path without leaving the house. Using &lt;code&gt;curl --resolve&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resolve&lt;/span&gt; analytics.nightcity.network:443:104.21.x.x &lt;span class="se"&gt;\&lt;/span&gt;
  https://analytics.nightcity.network/login | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that against a handful of paths and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/                  404
/login             404
/bundle.js         200
/script.js         404
/api/v1/data       405
/api/send          404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;405&lt;/code&gt; is the collect endpoint telling me it only accepts &lt;code&gt;POST&lt;/code&gt;, which is exactly what I want to see from a &lt;code&gt;GET&lt;/code&gt;. It does admit the endpoint exists, where a &lt;code&gt;404&lt;/code&gt; would not, but that one has to be findable anyway. Everything else gets a polite middle finger.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Something worth pointing out.
&lt;/h3&gt;

&lt;p&gt;I updated the ingress config, re-ran my checks and &lt;code&gt;/script.js&lt;/code&gt; came back with a &lt;code&gt;200&lt;/code&gt;. Which was alarming, because I had just watched it &lt;code&gt;404&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;404&lt;/code&gt; immediately confirming the origin was doing its job.&lt;/p&gt;

&lt;p&gt;Worth knowing if you ever unpublish something: the edge does not find out until its copy expires. Cache invalidation strikes again!&lt;/p&gt;

&lt;h2&gt;
  
  
  Hark! A plot twist!
&lt;/h2&gt;

&lt;p&gt;Just after I shipped the script tag and confirmed real traffic was hitting Umami, Firefox showed me this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv2j3rabap8of1dk0b4rw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv2j3rabap8of1dk0b4rw.png" alt="The Firefox popup for Local Network Access." width="800" height="335"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Go home Firefox, you are drunk.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Sir, this is my own site from my machine on my home network. How &lt;em&gt;dare&lt;/em&gt; you?&lt;/p&gt;

&lt;p&gt;As I mentioned earlier, I run split horizon DNS. Inside my network, &lt;code&gt;analytics.nightcity.network&lt;/code&gt; resolves to a private address on my LAN so I get the dashboard directly. Outside, where &lt;em&gt;you better be&lt;/em&gt; reading from, the same name resolves to Cloudflare and goes down the tunnel. That's what makes the two-path thing work. I also mentioned this before in my previous article &lt;a href="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/" rel="noopener noreferrer"&gt;My Blog Now Ships From My Homelab!&lt;/a&gt; and it's one of my favourite aspects about my setup.&lt;/p&gt;

&lt;p&gt;But when I load my &lt;em&gt;public&lt;/em&gt; blog from my &lt;em&gt;own house&lt;/em&gt;, the browser sees a page served from a public origin trying to load a subresource from &lt;code&gt;10.x.x.x&lt;/code&gt;. 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 &lt;a href="https://support.mozilla.org/en-US/kb/control-personal-device-local-network-permissions-firefox" rel="noopener noreferrer"&gt;Local Network Access&lt;/a&gt; and blocks it by default. Chrome is heading the same way.&lt;/p&gt;

&lt;p&gt;So the one place on earth where my analytics silently do not work is the chair I'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't answer for, so it goes out to the edge from everywhere including my lounge room. I won't be doing that as I'm all too happy skipping my own page views.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one thing path scoping does not fix.
&lt;/h2&gt;

&lt;p&gt;Moving the admin interface off the internet does nothing for the collect endpoint. That endpoint has to accept anonymous &lt;code&gt;POST&lt;/code&gt;s from every visitor's browser, or it isn't an analytics endpoint. Umami rejects requests carrying a website ID it doesn't recognise, which stops idle nonsense, but my real website ID is sitting in the page source of every page on this site and &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt;. Literally anyone can read it.&lt;/p&gt;

&lt;p&gt;So the only real control is applying some kind of rate limit. Cloudflare's free tier, which I'm currently on because I'm cheap and actually trying to &lt;em&gt;reduce&lt;/em&gt; my subscription costs, permits one rule at a ten second window and it insists on counting per &lt;em&gt;data centre&lt;/em&gt; rather than &lt;em&gt;globally&lt;/em&gt;. It'll stop something stupid. But, it will not stop something determined.&lt;/p&gt;

&lt;p&gt;Basically, path scoping shrinks the surface area enormously and then stops. What's left is the stuff that's &lt;em&gt;supposed&lt;/em&gt; 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.&lt;/p&gt;
&lt;h2&gt;
  
  
  ... so, naturally I put Nginx in front of it.
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h3&gt;
  
  
  The origin is blind.
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;172.16.x.x&lt;/code&gt;, the Docker address of &lt;code&gt;cloudflared&lt;/code&gt;, for the entire internet. That's also why the usual advice of "just put fail2ban on it" doesn't really apply. &lt;a href="https://en.wikipedia.org/wiki/Fail2ban" rel="noopener noreferrer"&gt;Fail2ban&lt;/a&gt; blocks source addresses with a firewall rule and no packet ever arrives from the address you'd want to block.&lt;/p&gt;

&lt;p&gt;Cloudflare does pass the real client along in a &lt;code&gt;CF-Connecting-IP&lt;/code&gt; header. Nginx can promote that header back into being the actual client address, which makes both the logging and the limiting mean something:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;172.16&lt;/span&gt;&lt;span class="s"&gt;.0.0/12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;real_ip_header&lt;/span&gt;   &lt;span class="s"&gt;CF-Connecting-IP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="s"&gt;zone=collect:10m&lt;/span&gt; &lt;span class="s"&gt;rate=5r/s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;set_real_ip_from&lt;/code&gt; range tells Nginx only the connector may assert that header. Nginx answers &lt;code&gt;cloudflared&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Umami logs nothing at all. Zilch.
&lt;/h3&gt;

&lt;p&gt;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... my analytics. &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  In which I am humbled by a robot.
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://wilhelm.codes/bits/update-putting-the-site-on-a-diet/" rel="noopener noreferrer"&gt;tiny post&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;browser  os      device  screen      country  hits  sessions
chrome   Mac OS  laptop  1366x1366   SG       53    51
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are a few tells here. Not only is the screen size a perfect square, &lt;code&gt;50&lt;/code&gt;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 &lt;a href="https://kagi.com/smallweb" rel="noopener noreferrer"&gt;Kagi Small Web&lt;/a&gt;. 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.&lt;/p&gt;

&lt;p&gt;I don't care about the bots. The point is that I only found it because I'd added logging. I only added logging because I was chasing a rate limit I probably didn't need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small detail I liked most.
&lt;/h2&gt;

&lt;p&gt;Nginx has a default location block and mine looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;444&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;444&lt;/code&gt; is an &lt;a href="https://http.dev/444#configuring-444" rel="noopener noreferrer"&gt;Nginx special&lt;/a&gt;. It doesn't return a status, but closes the connection quietly.&lt;/p&gt;

&lt;p&gt;That rule is doing the same job as the catch-all &lt;code&gt;404&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;So, the tunnel is Cloudflare's. The edge rate limit is Cloudflare's. The Nginx config is a text file that's &lt;em&gt;mine&lt;/em&gt; and it doesn't care in the slightest what is in front of it. Which matters, because I'm not 100% sold on keeping Cloudflare around longer-term.&lt;/p&gt;

&lt;p&gt;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. &lt;a href="https://tailscale.com/" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt;, &lt;a href="https://pangolin.net/" rel="noopener noreferrer"&gt;Pangolin&lt;/a&gt;, &lt;a href="https://netbird.io/" rel="noopener noreferrer"&gt;NetBird&lt;/a&gt; or even vanilla &lt;a href="https://www.wireguard.com/" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt; will get you there. I picked Tunnels as a convenient stop-gap until I move on to something a bit more "self-hosty". The vendor is incidental. Publish only what &lt;em&gt;has&lt;/em&gt; to be public.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing ...
&lt;/h2&gt;

&lt;p&gt;None of this is novel. Tunnels have matched on path since forever, it's in the &lt;a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched" rel="noopener noreferrer"&gt;docs&lt;/a&gt; and I'm certain plenty of people are already doing exactly this. I just hadn't thought about it properly, because the well-trodden advice is "put an auth layer in front of it" and this kind of consensus is usually good enough that you stop thinking.&lt;/p&gt;

&lt;p&gt;But "protect the admin panel" and "publish the admin panel and &lt;em&gt;then&lt;/em&gt; protect it" are not the same sentence and you should be aware of the difference.&lt;/p&gt;

&lt;p&gt;If you're running anything through a tunnel right now, it's worth five minutes with your ingress config. Ask yourself which paths actually &lt;em&gt;need&lt;/em&gt; to leave your network. For a lot of self-hosted things the honest answer is "fewer than all of them". The best answer is "none". And if you'd rather find out than wonder, that &lt;code&gt;curl --resolve&lt;/code&gt; trick works against any hostname, including yours. Start with &lt;code&gt;/login&lt;/code&gt; and see what comes back.&lt;/p&gt;

&lt;p&gt;And if you're about to tell me the browser permission dialog is a bug, it isn't. It's three correct systems arguing and I've decided that's a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would you like to know more?
&lt;/h2&gt;

&lt;p&gt;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's where I'd start.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://web.mit.edu/Saltzer/www/publications/protection/" rel="noopener noreferrer"&gt;The Protection of Information in Computer Systems&lt;/a&gt;, Saltzer and Schroeder, 1975. The source of &lt;em&gt;fail-safe defaults&lt;/em&gt; ( deny by default, allow by exception, which is exactly what that catch-all &lt;code&gt;404&lt;/code&gt; rule is ) and &lt;em&gt;economy of mechanism&lt;/em&gt; ( fewer moving parts means fewer parts to get wrong ). It'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.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Attack_Surface_Analysis_Cheat_Sheet.html" rel="noopener noreferrer"&gt;Attack Surface Analysis&lt;/a&gt;, OWASP. The plain-English version of the whole post, minus my nonsense. Useful if you want a structured way to ask "what am I actually exposing" about something larger than a script tag.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched" rel="noopener noreferrer"&gt;Tunnel configuration file&lt;/a&gt;, Cloudflare. The ingress rules reference. This is the page I had skimmed past twice before noticing that &lt;code&gt;path&lt;/code&gt; was sitting right there next to &lt;code&gt;hostname&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wicg.github.io/local-network-access/" rel="noopener noreferrer"&gt;Local Network Access&lt;/a&gt;, WICG. The spec behind the permission dialog that made me raise an eyebrow. If you'd rather have prose than a spec, Chrome's &lt;a href="https://developer.chrome.com/blog/local-network-access" rel="noopener noreferrer"&gt;New permission prompt for Local Network Access&lt;/a&gt; covers the same ground and explains why browsers decided this needed fixing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;em&gt;far&lt;/em&gt; smarter than me.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>umami</category>
      <category>cloudflare</category>
      <category>analytics</category>
    </item>
    <item>
      <title>What Good is a Number You Can't Call?</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Thu, 10 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/what-good-is-a-number-you-cant-call-4lm</link>
      <guid>https://dev.to/wilhelm-codes/what-good-is-a-number-you-cant-call-4lm</guid>
      <description>&lt;p&gt;This one's relatively short. I feel like I've been posting about &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt; a bit too much lately. But I've made a small change to how the site displays emergency information and it matters enough to warrant its own post. So, here we go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some background.
&lt;/h2&gt;

&lt;p&gt;For three years every plant page on &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt; ended with the same two phone numbers and both of them were for the US. That never really sat right with me. I even flagged it as roadmap work when I &lt;a href="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/" rel="noopener noreferrer"&gt;rebuilt the site&lt;/a&gt;, then left it sitting there. The site's whole reason for existing is for the moment your cat eats something and you need an answer now. For most of the people who land there from a search engine, I was answering with a number they can't dial. So, I recently sat down to address that gap, assuming I'd spend a few minutes filling in a table of hotlines and be done with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every single country had a speed bump.
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sweden's poison centre stopped answering questions about animals in July 2018 because the human call volume got too high.&lt;/li&gt;
&lt;li&gt;The Dutch line only takes calls from vets, so if you're a pet owner in Rotterdam, the correct advice is "ring your vet and let them ring the line", which is not a phone number at all.&lt;/li&gt;
&lt;li&gt;Belgium answers animal calls only during the day and says outright on its own site that it isn't funded for animal work.&lt;/li&gt;
&lt;li&gt;The Australian helpline I'd have sworn was free actually starts at &lt;code&gt;$75&lt;/code&gt;. This is a bit embarrassing because I live here and &lt;em&gt;should&lt;/em&gt; know this.&lt;/li&gt;
&lt;li&gt;The UK's fee changes depending on the time of day you call.&lt;/li&gt;
&lt;li&gt;The ASPCA doesn't publish its consultation fee anywhere on its own page, so the figure everyone quotes online comes from third parties.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The list goes on.&lt;/p&gt;

&lt;p&gt;I'd have got at least half of these wrong from memory, which is a genuinely unsettling thing to realise about emergency information.&lt;/p&gt;

&lt;p&gt;Once again, I had to collate a bunch of disparate data and create a shape to support it before munging it all together. Every entry in the hotlines list now cites the operator's page it came from and the date I read it which is the &lt;a href="https://wilhelm.codes/blog/every-record-now-has-a-source/" rel="noopener noreferrer"&gt;same rule&lt;/a&gt; I put on the plant records in the last post. A Python script holds the whole list and nags me when an entry goes over a year old and it runs as part of the automated deployment. So, the build gives me a big'ole bonk on the nose whenever the list goes stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring technical stuff.
&lt;/h2&gt;

&lt;p&gt;The engineering ended up being the easy half and mostly a matter of deciding what fails safely. Hugo writes every country into the HTML as a partial. A small script then lifts the relevant one out and folds the rest away. It guesses in order: a country you've pinned, then the location filter if it names exactly one country, then your browser locale.&lt;/p&gt;

&lt;p&gt;It never asks permission for anything, because a permission prompt at 2am is the last thing you need while you're panicking about your pet. And, personally, I &lt;em&gt;hate&lt;/em&gt; browser prompts asking for location tracking. Hard pass. If the script doesn't run, or if the service worker hands you a stale page, you get all countries instead of the wrong one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Too many numbers costs you a few seconds. The wrong number costs you the call.&lt;/p&gt;

&lt;p&gt;— It me.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's also a new &lt;a href="https://plantsm.art/emergency/" rel="noopener noreferrer"&gt;emergency&lt;/a&gt; page for the first five minutes before you get through: what to do, what not to do and what they'll ask you. The guidance follows the ASPCA and the Pet Poison Helpline which is also cited and linked. After all, the one thing I'm definitely not qualified to write is clinical advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Another free gift: the hotlines API.
&lt;/h2&gt;

&lt;p&gt;There's really no obvious centralised place online that collates all this information into a single data set. I've at least done the work of bootstrapping the handful of most common countries that have helplines and the list will definitely grow over time. So, I've exposed a new "hotlines" &lt;a href="https://plantsm.art/api/hotlines.json" rel="noopener noreferrer"&gt;API endpoint&lt;/a&gt; with its own documented shape.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"countries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;more&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;countries&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"checked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"United States"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"services"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"audience"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"(888) 426-4435"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"fee"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A consultation fee applies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"hours"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"24 hours, every day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"org"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ASPCA Animal Poison Control Center"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.aspca.org/pet-care/animal-poison-control"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"tel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+18884264435"&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"audience"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"(855) 764-7661"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"fee"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$89 for each incident"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"hours"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"24 hours, every day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"org"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Pet Poison Helpline"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.petpoisonhelpline.com/about/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"tel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+18557647661"&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"notice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Emergency telephone numbers for animal poisoning. Each country cites the operator page the entry came from and the date a person read it. Services close and numbers change, so check an entry before you rely on it. Coverage is not global. A country in this file with an empty services list has been checked and has no service an owner can call. A country that is absent has not been checked."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"updated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-10"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I felt wrapping it in an &lt;a href="https://www.progress.com/blogs/envelope-design-pattern" rel="noopener noreferrer"&gt;envelope&lt;/a&gt; was the correct approach here. It leads with the notice and the date I last changed anything. A &lt;code&gt;.countries&lt;/code&gt; object follows, keyed by ISO 3166-1 alpha-2 code. At the very least, it gives me room to change a country's metadata later without breaking the top-level shape.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;audience&lt;/code&gt; field on each service is what carries the Dutch problem into the data. It reads either &lt;code&gt;owner&lt;/code&gt; or &lt;code&gt;vet&lt;/code&gt;. A &lt;code&gt;vet&lt;/code&gt; entry means the number exists but you can't ring it yourself, so the entry tells you to phone your vet and let them make the call.&lt;/p&gt;

&lt;p&gt;Anyway, along with some stylistic changes and code cleanup, that's pretty much all there is to it for this update. I'll be giving &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt; a rest for a bit while all the changes settle in. This gives me some time to work on other fun things!&lt;/p&gt;

&lt;p&gt;Enjoy, nerds!&lt;/p&gt;

</description>
      <category>plantsmart</category>
      <category>api</category>
      <category>updates</category>
      <category>hugo</category>
    </item>
    <item>
      <title>Every Record Now Has a Source</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Sat, 05 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/every-record-now-has-a-source-id0</link>
      <guid>https://dev.to/wilhelm-codes/every-record-now-has-a-source-id0</guid>
      <description>&lt;p&gt;As an experiment, last week I introduced &lt;a href="https://www.reddit.com/r/InternetIsBeautiful/" rel="noopener noreferrer"&gt;r/InternetIsBeautiful&lt;/a&gt; to &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt;. To my surprise, it was overall very &lt;a href="https://www.reddit.com/r/InternetIsBeautiful/comments/1w5utet/i_got_tired_of_frantically_googling_is_this_plant/" rel="noopener noreferrer"&gt;well received&lt;/a&gt;! Obviously, there were a few inevitable bad-faith comments from your standard-issue dickheads, but along with a lot of positive vibes I also got heaps of great suggestions and feature requests. So, this post covers not only a database scrub, but everything I've added as a result of the thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database lost some weight.
&lt;/h2&gt;

&lt;p&gt;Originally, the plant database held around &lt;code&gt;480&lt;/code&gt; records. It now holds around &lt;code&gt;280&lt;/code&gt;, so roughly &lt;code&gt;200&lt;/code&gt; records are gone. About &lt;code&gt;170&lt;/code&gt; of them came from a single symptom list and all of them included "cardiac failure". That one symptom pushed every associated record to the top severity level. They read as "critical" because of the shared symptom list, not because of the plant itself. The list came from older sourced data that I never properly scrubbed and vetted, so I nixed those records entirely from the primary data set.&lt;/p&gt;

&lt;p&gt;Sucks, but a smaller database you can trust beats a bigger one you cannot.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I source vetted data.
&lt;/h3&gt;

&lt;p&gt;At the moment, I check each record against the following authoritative data sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://www.aspca.org/" rel="noopener noreferrer"&gt;ASPCA&lt;/a&gt; toxic plant list. It gives the clinical signs for dogs, cats and horses. This is the original reference that started the project way back in December of 2022.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://poisonousplants.cvmbs.colostate.edu/" rel="noopener noreferrer"&gt;Guide to Poisonous Plants&lt;/a&gt; from the College of Veterinary Medicine and Biomedical Sciences at Colorado State University. It gives the clinical signs for each body system and it names the animals that the plant affects. It is stronger than the ASPCA list on horses and other farm animals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of the &lt;code&gt;280&lt;/code&gt; or so records that remain traces back to at least one of these two references. This is a &lt;em&gt;hard&lt;/em&gt; requirement. If a plant can't clear it, it doesn't get a record.&lt;/p&gt;

&lt;p&gt;Both references are copyrighted material. I cite them and link to them directly without publishing any of their text. Each record holds my own symptom names only. If you're curious, the &lt;a href="https://plantsm.art/attributions/" rel="noopener noreferrer"&gt;attributions page&lt;/a&gt; lists every licensed source used to build the site.&lt;/p&gt;

&lt;p&gt;If you know of another authoritative data set that covers animals, the plants that are toxic to them and the associated symptoms, please let me know in the comments below or on the &lt;a href="https://plantsm.art/contribute/" rel="noopener noreferrer"&gt;contribute page&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I removed, specifically.
&lt;/h3&gt;

&lt;p&gt;I removed the inaccurate records and enriched and corrected the rest. These changes break down into three groups.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The old data set covered seven animal types. The update reduces coverage to cats, dogs and horses. That doesn't mean I've given up on birds, fish, reptiles and small mammals. If I find good data for any of those groups, they go straight back in. Until then, they're parked.&lt;/li&gt;
&lt;li&gt;I also changed the rule that sets the severity level of each record. The old rule just took the worst symptom on the record. Now there's an extra condition: a record with a single "critical" symptom and no "severe" symptoms gets marked "severe" instead. Nothing huge, but I felt it was worth mentioning.&lt;/li&gt;
&lt;li&gt;A handful of records turned out to be duplicates, so I merged them.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; A plant that is not in this database is a plant I cannot verify. If I can't verify it, it's not something I would personally call safe. Always consult your veterinarian when in doubt!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The plant index is now a "Field Guide".
&lt;/h2&gt;

&lt;p&gt;Reddit user &lt;a href="https://www.reddit.com/user/Abbot_of_Cucany/" rel="noopener noreferrer"&gt;u/Abbot_of_Cucany&lt;/a&gt; mentioned that their bird guide indexes each species twice: the common name in plain type and the scientific name in italic type. I thought this was a great idea, so I built it. Of course, I &lt;a href="https://plantsm.art/plants/" rel="noopener noreferrer"&gt;got a little carried away&lt;/a&gt;, so the page now also holds a filter box. Type part of a name and the page shows every matching plant on record. A letter row sticks to the top of the screen while you scroll and it marks the letter you're currently reading through.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3aami4yxl7ivsp9385ea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3aami4yxl7ivsp9385ea.png" alt="The field guide showing a partial filter match for " width="800" height="495"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The field guide, filtered on "pea".&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The front page got some filter updates!
&lt;/h2&gt;

&lt;p&gt;Some people requested more filters, so I delivered. The additions cover severity, plant shape and the geographical regions where you might come across a plant in the wild. Those last two data points were already sourced, but not really in use. At least not in any obvious way. I also added a newer, tighter, photo-centric grid view for smaller screens. Personally, I think it looks good, but I'm not sure if I'm going to keep it. Let me know what you think!&lt;/p&gt;

&lt;p&gt;The extra dropdowns squished everything together in the filter bar, so I had to rethink how to display them. I replaced all the individual dropdowns with a single multi-level filter menu. You can now click around and filter as you go. I also shrank the icons a bit to save more space, but they expand when you hover over them with a mouse or tap them on a small screen.&lt;/p&gt;

&lt;p&gt;Also, worth noting: your filter selections stay in the browser between visits.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmu49f1xvq286idkf5h01.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmu49f1xvq286idkf5h01.png" alt="Expanding the animal filter on hover." width="800" height="198"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgry53d64n9plmn42izd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgry53d64n9plmn42izd.png" alt="The top-level filter dropdown." width="744" height="774"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwr6s927wi1fbqwyejmm3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwr6s927wi1fbqwyejmm3.png" alt="The new shape filter." width="800" height="818"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff4pcfargipwpsk20jg8e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff4pcfargipwpsk20jg8e.png" alt="Default filter bar view." width="702" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkazpdk5xp371lx5aayl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkazpdk5xp371lx5aayl.png" alt="The new severity filter." width="800" height="625"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftibl520xjh1lc7pkk174.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftibl520xjh1lc7pkk174.png" alt="The new card view option." width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The data for the "Found in" filter comes from &lt;a href="https://www.gbif.org/" rel="noopener noreferrer"&gt;GBIF&lt;/a&gt;, the Global Biodiversity Information Facility. GBIF counts the wild sightings of each plant in each country. A country counts as a match when it holds one percent or more of the sightings for that plant. It's a totally arbitrary, non-scientific threshold that I imposed to keep the country list as tight as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safe plants get a facelift!
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://plantsm.art/safe/" rel="noopener noreferrer"&gt;safe plants list&lt;/a&gt; now holds over &lt;code&gt;30&lt;/code&gt; common household plants. The new entries include the spider plant, the bamboo palm, sage and baby's breath, among others. A new entry joins this list only when the ASPCA marks the plant as non-toxic and only when the plant is absent from my own database. Each entry also carries a note that names the toxic plant people confuse it with. For instance, bamboo palm is safe while sago palm is deadly.&lt;/p&gt;

&lt;p&gt;Based on a couple of user suggestions, each safe plant also includes a small gallery of three related photos sourced directly from the naturalist community at &lt;a href="https://www.inaturalist.org/" rel="noopener noreferrer"&gt;iNaturalist&lt;/a&gt;. Clicking a photo opens a lightbox with a zoomed-in view along with attribution and licensing information.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp43bakoeqprmtp5i4vky.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp43bakoeqprmtp5i4vky.png" alt="A preview of some safe plant entries." width="800" height="213"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A few entries from the new safe plants list.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It now has a blog... sort of.
&lt;/h2&gt;

&lt;p&gt;This part wasn't a suggestion. In addition to the &lt;a href="https://plantsm.art/notes/" rel="noopener noreferrer"&gt;updates&lt;/a&gt; page, I thought some kind of small blog would make sense. The articles I write about the site live &lt;em&gt;here&lt;/em&gt;, where visitors to &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt; will most likely never read them. So, instead of building actual blogging functionality, I decided to syndicate the articles from here. I updated my Hugo templates to generate an RSS feed for each tag. Every article about &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt; carries the &lt;code&gt;plantsm.art&lt;/code&gt; tag, so the site can ingest all the relevant content on its own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1bs1atpwbmyir84lin4a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1bs1atpwbmyir84lin4a.png" alt="A syndicated feed that's in desperate need of more content." width="800" height="567"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A syndicated feed that's in desperate need of more content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The page shows one featured note first, just the latest entry in the list. Five recent notes follow as cards. Finally, an archive section gives one row per note, grouped by year. There's just not enough content at the moment to fill that section out, so it stays hidden for now. Also, something you can't see just yet: a text filter appears after &lt;code&gt;15&lt;/code&gt; or so entries and works just like the one in the field guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finally, some changes to the API.
&lt;/h2&gt;

&lt;p&gt;The API gives one file for each animal in the database. Since the data scrub, four of those files no longer exist: &lt;code&gt;birds.json&lt;/code&gt;, &lt;code&gt;small-mammals.json&lt;/code&gt;, &lt;code&gt;reptiles.json&lt;/code&gt; and &lt;code&gt;fish.json&lt;/code&gt;. The database itself no longer holds plant records for those animals. The &lt;a href="https://plantsm.art/api/" rel="noopener noreferrer"&gt;API page&lt;/a&gt; documents the files that remain. Every other endpoint and every other shape stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next?
&lt;/h2&gt;

&lt;p&gt;I want the other four animal types back. A rabbit owner needs this data as much as a dog owner does. I'm on the lookout for a reference that gives the symptoms per plant for birds, fish, reptiles and small mammals, but there's just not a lot of authoritative data out there. If you know of one, tell me in the comments below or on the &lt;a href="https://plantsm.art/contribute/" rel="noopener noreferrer"&gt;contribute page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I also want those &lt;code&gt;170&lt;/code&gt; records back, but each one needs a reference that names the plant and gives the clinical signs. Until then, they stay on the back burner.&lt;/p&gt;

&lt;p&gt;That pretty much covers it. It's been a fairly hectic week of coding and rolling out updates, but it's been a lot of fun. At the time of writing, the &lt;a href="https://www.reddit.com/r/InternetIsBeautiful/comments/1w5utet/i_got_tired_of_frantically_googling_is_this_plant/" rel="noopener noreferrer"&gt;Reddit post&lt;/a&gt; sits at over &lt;code&gt;530&lt;/code&gt; upvotes! It's always risky sharing something you've made with the world, but more often than not, people appreciate it. It really put a smile on my face.&lt;/p&gt;

&lt;p&gt;Till next time!&lt;/p&gt;

</description>
      <category>plantsmart</category>
      <category>api</category>
    </item>
    <item>
      <title>Why I Rebuilt Plant Smart</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/why-i-rebuilt-plant-smart-3cnf</link>
      <guid>https://dev.to/wilhelm-codes/why-i-rebuilt-plant-smart-3cnf</guid>
      <description>&lt;p&gt;Back in June I wrote &lt;a href="https://wilhelm.codes/bits/blurb-i-should-update-plantsmart/" rel="noopener noreferrer"&gt;a little blurb&lt;/a&gt; that said, in its entirety, "I should update &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt;." Bolder and more visionary words have never been written. So bold, in fact, I went right back to &lt;a href="https://wilhelm.codes/blog/some-long-overdue-demolition/" rel="noopener noreferrer"&gt;demolishing this blog&lt;/a&gt; instead and let the poor thing sit for another two months.&lt;/p&gt;

&lt;p&gt;Well, I finally did the thing.&lt;/p&gt;

&lt;p&gt;Three and a half years ago I wrote [Why I Built &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt;](&lt;a href="https://wilhelm.codes/blog/why-i-built-plant-smart/" rel="noopener noreferrer"&gt;https://wilhelm.codes/blog/why-i-built-plant-smart/&lt;/a&gt;). A free and searchable database of plants that are toxic to pets. I built it over a holiday break to answer a simple question, "Can I bring this plant home without endangering the fluffy little gremlin that runs our household?" It was also my excuse to learn SvelteKit, TypeScript, TailwindCSS and Vite, because my frontend peers wouldn't shut the hell up about them.&lt;/p&gt;

&lt;p&gt;The site then chugged along quietly and untouched for years, which sounds like a compliment to its architecture and very much is not one to the maintainer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toolchain aged like fine milk.
&lt;/h2&gt;

&lt;p&gt;The thing no one really thinks about when they build something like this with shiny new frontend tools is that it doesn't rust, it &lt;em&gt;curdles&lt;/em&gt;. When I finally came back to give &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt; a fresh coat of paint, the build no longer ran. Not "a few deprecation warnings" didn't run. It didn't run at all. Two years of an unattended &lt;code&gt;package.json&lt;/code&gt; had fermented into something of a cheese-like consistency.&lt;/p&gt;

&lt;p&gt;My original requirements list from 2022 said the site must be fast, static, ( almost ) free to run and open with its data. Nowhere did it say "must still compile in two years".&lt;/p&gt;

&lt;p&gt;So, I decided to spend a few weeks off and on to throw out the toolchain and give it a much-needed refresh. Just like this site, Hugo now builds the entire thing. There's a single hand-written stylesheet and a few small scripts of delicious vanilla JavaScript. No &lt;code&gt;npm install&lt;/code&gt;, because there is no longer anything to install. If I disappear for another two years ( likely ), the site will build exactly the same the day I come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  First things first, absolute parity.
&lt;/h2&gt;

&lt;p&gt;The rebuild had one rule that saved me from myself: the first pass had to be a 1:1 replica of the old site. It needed the same pages, filters, URLs and quirks. The pass was meant to be boring on purpose. I had to resist the temptation of doing both the replatforming and redesigning all in one go.&lt;/p&gt;

&lt;p&gt;My primary constraint was the plant data. &lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt;'s whole gimmick is what I called a "dumb API". The entire primary database lives in &lt;a href="https://plantsm.art/api/plants.json" rel="noopener noreferrer"&gt;one JSON file&lt;/a&gt; with other, smaller, supplemental files you could use as lookup tables. Since this was all hosted on Cloudflare's CDN it was effectively free to use for others without keys or annoying rate limits. The old derivations were originally done by a pile of Go magefiles. The new site had to reproduce them exactly, down to the deliberately unsorted first-appearance ordering, or every downstream consumer would notice.&lt;/p&gt;

&lt;p&gt;Hugo, it turns out, can do all of it. A content adapter reads the database and births every plant page directly with no intermediate files. A handful of templates re-implement the old Go derivation logic and regenerate the API on every build. I diffed the generated files against the originals and they came back value-identical on the first honest attempt.&lt;/p&gt;

&lt;p&gt;Then came my favourite commit of the whole project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retire the sveltekit stack: remove src, magefiles and the node toolchain
566 files changed, 112 insertions(+), 9905 deletions(-)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I deleted nearly ten thousand lines and the site did more than before. This is every developer's &lt;em&gt;favourite&lt;/em&gt; kind of commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  I made the site cute on purpose.
&lt;/h2&gt;

&lt;p&gt;With the replica shipped, the redesign could actually be fun. The old site was fine but it was very much "2022 Tailwind starter" vibe. A lot of cool greys, hard shadows and gradients on anything that stood still. Meanwhile the logo, a randomised squishy little pastel potted plant with a face, deserved better surroundings.&lt;/p&gt;

&lt;p&gt;So, I set out to make the logo the design brief. Cream paper instead of white with warm espresso ink instead of a dull grey. Sage and moss greens with honey and clay accents were all lifted straight from the icon set. Every shadow is tinted warm because plain black shadows on cream look like stickers. The corners got a bit rounder and the controls became pills. The whole thing is set in &lt;a href="https://fonts.google.com/specimen/Fredoka" rel="noopener noreferrer"&gt;Fredoka&lt;/a&gt;, a rounded display face that weighs about &lt;code&gt;30kB&lt;/code&gt; which I thought fit perfectly with the cute little mascot.&lt;/p&gt;

&lt;p&gt;Here are a few before and after shots of the main content pages, with a bonus shot of the new pill navigation that smaller screens finally got:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvcsk71jh9g5ygctj2wwe.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvcsk71jh9g5ygctj2wwe.webp" alt="The homepage before the redesign." width="800" height="531"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqo2zp1h11e0zziy5ijpj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqo2zp1h11e0zziy5ijpj.webp" alt="The homepage as it currently stands." width="800" height="549"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb1ctpo77akdfkkpcufyl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb1ctpo77akdfkkpcufyl.webp" alt="The plant details page before the redesign." width="800" height="762"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx6eoxndrz5gxw6amij2j.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx6eoxndrz5gxw6amij2j.webp" alt="The plant details page as it currently stands." width="800" height="1284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgd2nnc6laqahx6q26xeu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgd2nnc6laqahx6q26xeu.webp" alt="The new pill navigation for mobile users." width="800" height="1755"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The data enrichments will continue until "Morella" improves.
&lt;/h2&gt;

&lt;p&gt;Get it? No? Ok, I'll see myself out.&lt;/p&gt;

&lt;p&gt;The database had severity ratings for every plant, computed from symptom groupings and the old site displayed them precisely nowhere. Now, every card has a severity pill rated from Mild to Critical. The pills are also clickable filters on the main page's plant cards. Search happens as you type and if you fumble the Latin a bit, a little &lt;a href="https://en.wikipedia.org/wiki/Levenshtein_distance" rel="noopener noreferrer"&gt;Levenshtein&lt;/a&gt; pass offers "Did you mean &lt;em&gt;Lilium&lt;/em&gt;?". I thought this was a little bit more helpful than what the old site did after literally joking about spelling mistakes in its empty state.&lt;/p&gt;

&lt;p&gt;A few other mentionable updates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://plantsm.art/safe/" rel="noopener noreferrer"&gt;safe plants list&lt;/a&gt;, because the site only ever told you what &lt;em&gt;not&lt;/em&gt; to buy. Every entry has to clear the ASPCA non-toxic list and be absent from my own toxic database before it's allowed in.&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://plantsm.art/symptoms/" rel="noopener noreferrer"&gt;symptom finder&lt;/a&gt; that allows you to perform a plant search backwards. You can click on multiple symptoms and it'll filter accordingly.&lt;/li&gt;
&lt;li&gt;An emergency strip on every plant page with the poison-control hotlines. Unfortunately, for now, these are US numbers. Localising them by region is on the roadmap.&lt;/li&gt;
&lt;li&gt;Wikipedia descriptions for nearly every plant credited under CC BY-SA.&lt;/li&gt;
&lt;li&gt;Around 3,000 common-name redirects, so &lt;code&gt;/plant/peace-lily/&lt;/code&gt; finally lands somewhere useful.&lt;/li&gt;
&lt;li&gt;Because it made sense, the whole thing is now a PWA and works completely offline. So, now you can use the site in a greenhouse with no signal, which is precisely where this site is most likely to get used.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;My friends can no longer accuse me of trying to teach people how to poison their neighbour's pets.&lt;/p&gt;

&lt;p&gt;— J'Accuse...!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once I started auditing the data properly, I started to see a few additional gaps. There were several plants with multiple entries under different, but similar, names. I deduped and merged the symptom data and also updated the severity groupings to make more sense.&lt;/p&gt;

&lt;p&gt;I also quickly realised that merging &lt;code&gt;seizure&lt;/code&gt; into &lt;code&gt;seizures&lt;/code&gt; would have silently promoted over a hundred plants to "Critical". The severity groups treated those two spellings as &lt;em&gt;different levels&lt;/em&gt;. The little cleaner script I threw together caught it during a dry run. The script grew a rule about never merging across severity levels as a result. The plants that genuinely deserved promotion got one. For instance &lt;a href="https://plantsm.art/plant/ageratina-altissima/" rel="noopener noreferrer"&gt;white snakeroot&lt;/a&gt;, the plant that killed Lincoln's mother, was somehow rated "Severe". The script properly re-rated it to "Critical".&lt;/p&gt;

&lt;p&gt;The enrichment didn't stop there. I sourced proper taxonomy for every record via GBIF, per-plant toxicity principles with a glossary and little world maps showing their native origins.&lt;/p&gt;

&lt;h2&gt;
  
  
  There was also quite a bit of fat to trim.
&lt;/h2&gt;

&lt;p&gt;Somewhere in all this I noticed every page weighed over &lt;code&gt;200kB&lt;/code&gt;. This included pages that were basically just copy. The culprits were painfully self-inflicted. There was an &lt;code&gt;85kB&lt;/code&gt; inline script shipping all ten animated logo SVGs to every single page. The same animal icon inlined seventy times on the front page. Oh, and let's not forget my personal favourite, the attributions page carrying &lt;code&gt;149kB&lt;/code&gt; of pure &lt;em&gt;indentation&lt;/em&gt; that a template loop had quietly emitted. I was shipping whitespace at scale.&lt;/p&gt;

&lt;p&gt;The fixes were old-school and very satisfying. A single fingerprinted and cached JS bundle, SVG sprites so each drawing only ships once and one well-placed whitespace trim yielded the following results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;page&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;home&lt;/td&gt;
&lt;td&gt;247 KB&lt;/td&gt;
&lt;td&gt;56 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;attributions&lt;/td&gt;
&lt;td&gt;238 KB&lt;/td&gt;
&lt;td&gt;24 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;safe plants&lt;/td&gt;
&lt;td&gt;270 KB&lt;/td&gt;
&lt;td&gt;33 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plant pages&lt;/td&gt;
&lt;td&gt;114 KB&lt;/td&gt;
&lt;td&gt;36 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No framework was harmed, or indeed present, in the making of this facelift.&lt;/p&gt;

&lt;h2&gt;
  
  
  We ship from the homelab now.
&lt;/h2&gt;

&lt;p&gt;The last box to tick from the original post was the bittersweet one. In 2022 I wanted the project "open-source and community-driven" on GitHub. In practice, the community contribution was me, at midnight, fixing my own typos. So, the repository followed this blog into my homelab using the exact same moving parts as outlined in &lt;a href="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/" rel="noopener noreferrer"&gt;My Blog Now Ships From My Homelab!&lt;/a&gt;. It just made sense after this site's migration. The &lt;em&gt;data&lt;/em&gt; remains as open as ever through the API, which was always the part worth opening. That will never change.&lt;/p&gt;

&lt;p&gt;While migrating I had a small panic about the 315MB of plant photos sitting in my working tree, imagining every CI build hauling them across the network. Turns out my 2023 self had already gitignored them and the entire repository clones at 7.5MB. Every once in a blue moon past me does something right. I've decided to let sleeping dogs lie and not investigate what else he buried in there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was the juice worth the squeeze?
&lt;/h2&gt;

&lt;p&gt;Very much so. The site is faster, cuter and finally says the most important thing it knows on every card: &lt;em&gt;how&lt;/em&gt; dangerous is this plant, not just &lt;em&gt;whether&lt;/em&gt;. It builds from nothing with a single binary and deploys itself when I push. The data is cleaner and more robust than it has ever been and enriched well past what 2022 me had the patience for.&lt;/p&gt;

&lt;p&gt;But the honest win is the same one as the blog demolition. The whole thing is now boring to operate and boring is what lets a hobby project survive its maintainer's attention span.&lt;/p&gt;

&lt;p&gt;If you have a plant-curious pet, [&lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;Plant Smart&lt;/a&gt;](&lt;a href="https://plantsm.art" rel="noopener noreferrer"&gt;https://plantsm.art&lt;/a&gt;) is free, the &lt;a href="https://plantsm.art/api/" rel="noopener noreferrer"&gt;API&lt;/a&gt; is free and the &lt;a href="https://plantsm.art/safe/" rel="noopener noreferrer"&gt;safe list&lt;/a&gt; might even let you buy something nice for yourself. And if four years from now there's a third post in this series, may it be shorter than this one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The data enrichments will continue until "Morella" improves.&lt;/p&gt;

&lt;p&gt;— C'mon, this was a GREAT pun!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Anyways, adios...&lt;/p&gt;

</description>
      <category>hugo</category>
      <category>plantsmart</category>
      <category>woodpecker</category>
      <category>performance</category>
    </item>
    <item>
      <title>My Blog Now Ships From My Homelab!</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/my-blog-now-ships-from-my-homelab-5644</link>
      <guid>https://dev.to/wilhelm-codes/my-blog-now-ships-from-my-homelab-5644</guid>
      <description>&lt;p&gt;If you've given GitHub's status page a quick glance recently, you'll immediately see that it's been &lt;em&gt;going through it&lt;/em&gt;. Every outage is a small reminder that maybe we shouldn't rely too much on single points of failure we can't directly control.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;this&lt;/em&gt; site to the homelab. Now, it lives on my own &lt;a href="https://forgejo.org" rel="noopener noreferrer"&gt;Forgejo&lt;/a&gt; forge and the builds run on my own &lt;a href="https://woodpecker-ci.org" rel="noopener noreferrer"&gt;Woodpecker&lt;/a&gt; cluster. The only things that leave the network are static build artefacts that get pushed up to Cloudflare Pages.&lt;/p&gt;

&lt;p&gt;The switch was surprisingly straightforward and took less than 30 minutes overall.&lt;/p&gt;

&lt;p&gt;Before anyone reaches for the comment box, no, you don'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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How complex can this possibly be?
&lt;/h2&gt;

&lt;p&gt;The first useful thing I did was ask myself what GitHub was &lt;em&gt;actually&lt;/em&gt; doing for me when it comes to managing this site. After some mulling about, I could only really list the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hosting a git repository.&lt;/li&gt;
&lt;li&gt;Hosting the comment threads, via &lt;a href="https://giscus.app" rel="noopener noreferrer"&gt;giscus&lt;/a&gt; Discussions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's pretty much it. I don't use Actions and GitHub alone never built this site. &lt;a href="https://pages.cloudflare.com" rel="noopener noreferrer"&gt;Cloudflare Pages&lt;/a&gt; watched the &lt;code&gt;main&lt;/code&gt; branch and did the building itself. This is something I covered back in &lt;a href="https://wilhelm.codes/blog/my-blog-publishing-setup/" rel="noopener noreferrer"&gt;My Blog Publishing Setup&lt;/a&gt;. GitHub's one job was holding a bunch of markdown files.&lt;/p&gt;

&lt;p&gt;That's a strange thing to accept as a single point of failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new shape of things.
&lt;/h2&gt;

&lt;p&gt;The replacement looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I push to Forgejo, which lives inside my homelab network.&lt;/li&gt;
&lt;li&gt;Forgejo fires off a message to the Woodpecker Server over a webhook. Also inside the network.&lt;/li&gt;
&lt;li&gt;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 &lt;a href="https://developers.cloudflare.com/workers/wrangler/" rel="noopener noreferrer"&gt;wrangler&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Cloudflare serves the files at the edge, exactly as before.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I just added support for mermaid diagrams, so of course I'm not going to let the opportunity to show it off slide:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant D as The desk
    participant F as Forgejo
    participant W as Woodpecker
    participant C as Pages
    D-&amp;gt;&amp;gt;F: git push ( main )
    F-&amp;gt;&amp;gt;W: webhook
    W-&amp;gt;&amp;gt;W: diagrams, changelog, hugo --gc
    W-&amp;gt;&amp;gt;C: wrangler pages deploy
    C--&amp;gt;&amp;gt;D: the same site, but newer&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The part I want you to notice is the direction of travel. Under the old setup, Cloudflare reached &lt;em&gt;into&lt;/em&gt; 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 &lt;em&gt;outbound&lt;/em&gt; upload of compiled static assets.&lt;/p&gt;

&lt;p&gt;That reversal is the entire security model which was pretty much the point. What looks like "the old way but more steps" allows me to control every aspect of the pipeline up to &lt;em&gt;just&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;If my homelab bursts into flames mid-afternoon ( a real possibility as I &lt;em&gt;do&lt;/em&gt; live in Queensland, Australia ) Cloudflare keeps serving the last deploy and nobody visiting the site would ever know. That being said, nobody actually &lt;em&gt;does&lt;/em&gt; visit this site, so...&lt;/p&gt;

&lt;h2&gt;
  
  
  A small intermission.
&lt;/h2&gt;

&lt;p&gt;A short aside, because it's a question &lt;em&gt;I'd&lt;/em&gt; ask. My internal services hang off a domain I genuinely own, but resolving it depends on where you're standing. Inside the network a locally hosted 3-node &lt;a href="https://technitium.com/dns/" rel="noopener noreferrer"&gt;Technitium&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;Same hostname and two completely different worlds depending on which side of the wall you're on. &lt;a href="https://en.wikipedia.org/wiki/Split-horizon_DNS" rel="noopener noreferrer"&gt;Split-horizon DNS&lt;/a&gt; is nothing new, but it's what lets me use real TLS-friendly hostnames for internal services without those services ever being reachable from the internet.&lt;/p&gt;

&lt;p&gt;If I &lt;em&gt;did&lt;/em&gt; decide to open some services to the outside world, like my Navidrome server, I could use an overlay network like &lt;a href="https://netbird.io" rel="noopener noreferrer"&gt;Netbird&lt;/a&gt; to provide friends and family access via a familiar naming convention without ever opening a port on the router.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part Cloudflare doesn't clearly advertise.
&lt;/h2&gt;

&lt;p&gt;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, &lt;code&gt;wrangler pages deploy&lt;/code&gt; will be rejected and you will get screamed at.&lt;/p&gt;

&lt;p&gt;Disconnect the git integration in the project settings and the project quietly converts to "Direct Upload" 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're in there, make sure the production branch is properly specified: &lt;code&gt;main&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;After that, the deploy is one command requiring two secrets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An API token scoped to &lt;code&gt;Cloudflare Pages: Edit&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Your Cloudflare account ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both go into the Woodpecker Server as repository secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finally, our favourite! YAML.
&lt;/h2&gt;

&lt;p&gt;The whole thing is just one file named &lt;code&gt;.woodpecker.yml&lt;/code&gt; that squats in the repository root.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;manual&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;

&lt;span class="na"&gt;clone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;git&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;woodpeckerci/plugin-git&lt;/span&gt;
    &lt;span class="na"&gt;settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;partial&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;diagrams&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minlag/mermaid-cli:11.16.1&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./bin/render-diagrams&lt;/span&gt;

  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hugomods/hugo:debian-git-0.163.3&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;HUGO_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
      &lt;span class="na"&gt;HUGO_ENVIRONMENT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git config --global --add safe.directory '*'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./bin/changelog&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;hugo --gc&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:22-slim&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;CLOUDFLARE_API_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;from_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloudflare_api_token&lt;/span&gt;
      &lt;span class="na"&gt;CLOUDFLARE_ACCOUNT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;from_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloudflare_account_id&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
        &lt;span class="s"&gt;npx --yes wrangler@4 pages deploy public&lt;/span&gt;
        &lt;span class="s"&gt;--project-name=wilhelm-codes&lt;/span&gt;
        &lt;span class="s"&gt;--branch=main&lt;/span&gt;
        &lt;span class="s"&gt;--commit-hash="${CI_COMMIT_SHA}"&lt;/span&gt;
    &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Only build off &lt;code&gt;main&lt;/code&gt;.
&lt;/h3&gt;

&lt;p&gt;I test design changes and new entries locally, so a push to any other branch doesn't start a pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  We want a full clone, not a shallow one.
&lt;/h3&gt;

&lt;p&gt;This site's &lt;a href="https://wilhelm.codes/changelog/" rel="noopener noreferrer"&gt;changelog page&lt;/a&gt; renders the git log and Hugo's &lt;code&gt;enableGitInfo&lt;/code&gt; reads commit dates for every page. This is something I wrote about in &lt;a href="https://wilhelm.codes/blog/a-changelog-that-builds-itself/" rel="noopener noreferrer"&gt;A Changelog That Builds Itself&lt;/a&gt;. CI systems love shallow clones and a shallow clone here means I can't convey the full historical picture on the page. Worse, &lt;code&gt;enableGitInfo&lt;/code&gt; derives every article's dates from that same truncated log, so they all come out wrong besides. Setting &lt;code&gt;partial: false&lt;/code&gt; makes Woodpecker fetch the entire history.&lt;/p&gt;

&lt;h3&gt;
  
  
  Diagrams render before anything else.
&lt;/h3&gt;

&lt;p&gt;Articles on this site can embed &lt;a href="https://mermaid.js.org" rel="noopener noreferrer"&gt;Mermaid&lt;/a&gt; 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'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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The build step is a stock Hugo image.
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;git&lt;/code&gt; and &lt;code&gt;bash&lt;/code&gt;. Both of which the changelog script that generates the data requires. The &lt;code&gt;safe.directory&lt;/code&gt; line exists because the cloned workspace is owned by a different user than the one inside the container and &lt;code&gt;git&lt;/code&gt; these days refuses to read a repository it thinks you stole.&lt;/p&gt;

&lt;h3&gt;
  
  
  The deploy step is a stock Node image.
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;npx&lt;/code&gt; pulls Wrangler at run time. Wrangler then reads the two secrets from the environment and pushes &lt;code&gt;public/&lt;/code&gt; up to Pages and that's pretty much it. Dependencies are snagged and cached at build time, which means nothing to maintain on the agents. Either step's version gets bumped by editing one line.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info:&lt;/strong&gt; I already have &lt;a href="https://distribution.github.io" rel="noopener noreferrer"&gt;distribution&lt;/a&gt; hosted in the homelab for my local Docker images. I could easily configure it to support &lt;a href="https://distribution.github.io/distribution/recipes/mirror/" rel="noopener noreferrer"&gt;pull-through caching&lt;/a&gt;, so I don't keep having to pull directly from Docker Hub every time a build agent's local cache expires.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Complete parity with what Cloudflare's own build pipeline was doing, except now I can read every line of it and make it do whatever I need.&lt;/p&gt;

&lt;h2&gt;
  
  
  I did run into some speed bumps along the way.
&lt;/h2&gt;

&lt;p&gt;For the record, it took only three tries to get the pipeline to a "green" state. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwk7o4688rzw9v5ke3prn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwk7o4688rzw9v5ke3prn.png" alt="A Woodpecker Server build history screenshot showing the first 3 attempts." width="799" height="252"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Third time's a charm.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In order of discovery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The plain version tags of the Hugo image &lt;em&gt;are&lt;/em&gt; the extended edition. The &lt;code&gt;reg-&lt;/code&gt; prefix means regular and none of them include &lt;code&gt;git&lt;/code&gt; unless the tag says &lt;code&gt;git&lt;/code&gt;. The tag you guess first ( &lt;code&gt;exts-&amp;lt;version&amp;gt;&lt;/code&gt; ) doesn't exist. It's an odd convention, which is why it threw me off.&lt;/li&gt;
&lt;li&gt;Woodpecker substitutes &lt;code&gt;${VAR}&lt;/code&gt; expressions in the YAML &lt;em&gt;before&lt;/em&gt; the shell ever runs. Anything it doesn't know becomes an empty string. My &lt;code&gt;safe.directory "${CI_WORKSPACE}"&lt;/code&gt; quietly became &lt;code&gt;safe.directory ""&lt;/code&gt;. Hence the asterisk / glob in the YAML.&lt;/li&gt;
&lt;li&gt;The changelog script opens with &lt;code&gt;#!/usr/bin/env bash&lt;/code&gt; and the default Hugo images are Alpine. &lt;code&gt;env: can't execute 'bash'&lt;/code&gt; is the whole error message you get, so I just went with the Debian variants instead which include &lt;code&gt;bash&lt;/code&gt; out of the box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are hard problems, but worth pointing out.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'm not deleting the old repository... completely.
&lt;/h2&gt;

&lt;p&gt;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's Discussions feature. Delete the repo and every comment ever left on this site goes with it; all 2 of them.&lt;/p&gt;

&lt;p&gt;It no longer needed the full source history to do that job either. It was ported on over to my local forge with Forgejo'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'll eventually host the companion code for tutorial content, so readers can clone examples without ever touching my forge.&lt;/p&gt;

&lt;p&gt;Each service kept exactly the job it'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'd rather lick the rim of a pub toilet. GitHub Discussions gives commenters an identity they already have. Everything else now lives at home.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing ...
&lt;/h2&gt;

&lt;p&gt;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'm not here to convince anyone.&lt;/p&gt;

&lt;p&gt;There'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 &lt;em&gt;this&lt;/em&gt; project anyway.&lt;/p&gt;

</description>
      <category>hugo</category>
      <category>forgejo</category>
      <category>cloudflare</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Glazier Release: v0.1.5</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Mon, 24 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/glazier-release-v015-2d7j</link>
      <guid>https://dev.to/wilhelm-codes/glazier-release-v015-2d7j</guid>
      <description>&lt;p&gt;Today, I minted and tagged &lt;a href="https://github.com/wilhelm-murdoch/glazier" rel="noopener noreferrer"&gt;Glazier&lt;/a&gt; &lt;code&gt;v0.1.5&lt;/code&gt;. Immediately after, realised I never announced &lt;code&gt;v0.1.4&lt;/code&gt;, the release with all the actual features in it. So, Happy Birthday, you get a twofer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's get &lt;code&gt;v0.1.5&lt;/code&gt; out of the way first.
&lt;/h2&gt;

&lt;p&gt;There's not much to see here, honestly. This is a routine housekeeping and dependency bump release. The only change worthy of a mention is is a bump of &lt;code&gt;golang.org/x/text&lt;/code&gt; to address &lt;a href="https://pkg.go.dev/vuln/GO-2026-5970" rel="noopener noreferrer"&gt;GO-2026-5970&lt;/a&gt;. This is the sort of thing you patch promptly even when the practical exposure for a tmux wrapper is &lt;code&gt;nil&lt;/code&gt; ( ha-ha-ha ). Alongside that, the Go toolchain moved up to &lt;code&gt;1.26.4&lt;/code&gt; and &lt;code&gt;testify&lt;/code&gt;, &lt;code&gt;urfave/cli&lt;/code&gt; and a stack of GitHub Actions all got their routine nudges courtesy of Dependabot. Hey, thanks little buddy!&lt;/p&gt;

&lt;p&gt;Download the latest release, replace your current binary and you should be good to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;v0.1.4&lt;/code&gt; is where the features live.
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info:&lt;/strong&gt; Before we get into it I should point out this release introduces a few breaking changes to the spec that should be easy enough to resolve thanks to Glazier's awesome built-in error reporting.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Back in July I wrote about &lt;a href="https://wilhelm.codes/blog/i-couldnt-talk-myself-out-of-typed-variables/" rel="noopener noreferrer"&gt;adding typed variable blocks to Glazier&lt;/a&gt;. That work shipped on July 17th as &lt;code&gt;v0.1.4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The short recap, if you didn't read that post is that profiles can now declare the inputs they accept as first-class &lt;code&gt;variable&lt;/code&gt; blocks. Just like, you guessed it, Terraform. Each block can carry a primitive type (&lt;code&gt;string&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt; or &lt;code&gt;bool&lt;/code&gt;), a &lt;code&gt;default&lt;/code&gt; and a &lt;code&gt;description&lt;/code&gt;. No default means the variable is required. Pass a &lt;code&gt;--var&lt;/code&gt; the profile never declared and you get a proper located error. &lt;/p&gt;

&lt;p&gt;Values are read through the &lt;code&gt;var.&lt;/code&gt; namespace and only that namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"district"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"the district the gig is themed after"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"watson"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"fixer"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gig-${var.district}"&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.fixer}-ops"&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"echo ${var.fixer} has the next job"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze up &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;fixer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wakako
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze up &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;district&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;arasaka &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;fixer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wakako
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That post covers the design reasoning, the type coercion and the breaking namespace migration in detail. So, no need to reshash it all here. What it &lt;em&gt;doesn't&lt;/em&gt; cover is everything else that snuck into the release after I hit publish button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Support for locals.
&lt;/h2&gt;

&lt;p&gt;Variables are &lt;em&gt;inputs&lt;/em&gt;. They cross the boundary from the outside world into your profile via &lt;code&gt;--var&lt;/code&gt; flags, var files or defaults. But the moment I had proper variables I ran into their natural companion problem; &lt;em&gt;derived&lt;/em&gt; values. The thing you compute from an input once and then want to reuse in five places without copy-pasting the same expression five times.&lt;/p&gt;

&lt;p&gt;Terraform solves this with &lt;code&gt;locals&lt;/code&gt; and, because Glazier remains an unapologetic love letter to Terraform's parser, it does as well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"district"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"night city"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;slug&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;district&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gig-${local.slug}"&lt;/span&gt;
  &lt;span class="nx"&gt;logfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.pwd}/logs/${local.slug}.log"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.slug}-ops"&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tail -f ${local.logfile}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"editor"&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"nvim"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You declare named values in a &lt;code&gt;locals&lt;/code&gt; block at the top level and read them back through &lt;code&gt;local.&amp;lt;name&amp;gt;&lt;/code&gt;. Plural block and a singular reference is exactly how Terraform does it.&lt;/p&gt;

&lt;p&gt;A local can reference the &lt;code&gt;var.*&lt;/code&gt;, &lt;code&gt;env.*&lt;/code&gt;, &lt;code&gt;path.*&lt;/code&gt; namespaces, the whole function library and, crucially, &lt;em&gt;other locals&lt;/em&gt;. In any order. This works fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gig-${local.slug}"&lt;/span&gt;                    &lt;span class="c1"&gt;# references slug...&lt;/span&gt;
  &lt;span class="nx"&gt;slug&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;district&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;# ...declared after it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood resolution just keeps iterating, evaluating whatever it can each pass until the end. Whatever's left over then reports its &lt;em&gt;real&lt;/em&gt; evaluation diagnostics, so a genuine fat-fingered mistake surfaces as the actual error rather than being masked by ordering machinery. If you accidentally declare the same local twice you'll get told exactly where both declarations live.&lt;/p&gt;

&lt;p&gt;The one thing you can't do with a local is set it from the command line and this is by design. Variables are the contract with the outside world; locals are private. If a value should be overridable, it's a variable. If it's just an expression you're tired of repeating, it's a local. Having the language enforce that distinction is one of those small things that keeps a profile clean as it grows.&lt;/p&gt;

&lt;p&gt;They also pair nicely with the new &lt;code&gt;random&lt;/code&gt; function and HCL's inline comprehensions, both of which landed in this release too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;editors&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"nvim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"hx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"vim"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;greeting&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;random&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hello"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"choom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"wake up samurai"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;random(list)&lt;/code&gt; returns a uniformly random element of a list as a string. Did anyone ask for this? Absolutely not, but you deserve nice things.&lt;/p&gt;

&lt;h2&gt;
  
  
  lol... lmfao.
&lt;/h2&gt;

&lt;p&gt;The July post ended with me musing about adding &lt;code&gt;*.tfvars&lt;/code&gt;-style functionality and asking, and I quote, "Surely, I won't fall for this again?"&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;He, indeed, fell for this again.&lt;/p&gt;

&lt;p&gt;— narrator&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Yeah, so &lt;code&gt;v0.1.4&lt;/code&gt; also ships with &lt;code&gt;--var-file&lt;/code&gt; support:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# gig.vars&lt;/span&gt;
&lt;span class="nx"&gt;district&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"japantown"&lt;/span&gt;
&lt;span class="nx"&gt;fixer&lt;/span&gt;    &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"wakako"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze up &lt;span class="nt"&gt;--var-file&lt;/span&gt; gig.vars
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze up &lt;span class="nt"&gt;--var-file&lt;/span&gt; gig.vars &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;fixer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dino
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A var file is plain HCL; a &lt;code&gt;name = value&lt;/code&gt; attributes written once per line. I originally supported JSON files too and then ripped that out before release, because maintaining a second parse path for a format nobody was going to use failed the smell test. In other words, "YAGNI".&lt;/p&gt;

&lt;p&gt;Precedence works the way you'd expect: defaults go first, a var file overrides defaults and an explicit &lt;code&gt;--var&lt;/code&gt; flag always has the final say. In the second command above &lt;code&gt;fixer&lt;/code&gt; comes out as &lt;code&gt;dino&lt;/code&gt; no matter what the file thinks.&lt;/p&gt;

&lt;p&gt;Strictness from the flag side applies here too. An entry in a var file that names a variable the profile never declared is an error. Values are coerced to their declared types and every problem in the file is reported all at once for convenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our first contributor has entered the chat!
&lt;/h2&gt;

&lt;p&gt;This release contains the first fix from an actual outside contributor. &lt;a href="https://github.com/ThreeToes" rel="noopener noreferrer"&gt;ThreeToes&lt;/a&gt; tracked down an annoying initialisation bug where the first window in a profile would be ignored and cleaned up on startup. Leaving you with either a stray default terminal or only the subsequent windows.&lt;/p&gt;

&lt;p&gt;Someone who isn't me not only ran the thing, they read the source and fixed it. That's a genuinely lovely milestone for a hobby project. Thanks, man!&lt;/p&gt;

&lt;p&gt;Oh, and the repo finally has a proper &lt;a href="https://github.com/wilhelm-murdoch/glazier/blob/main/SPEC.md" rel="noopener noreferrer"&gt;SPEC.md&lt;/a&gt;. A full reference for the profile format describing every block, namespace and function.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing ...
&lt;/h2&gt;

&lt;p&gt;Release notes are like backups. Everyone agrees they're important and nobody does them until it's embarrassing.&lt;/p&gt;

&lt;p&gt;As always, it's &lt;a href="https://github.com/wilhelm-murdoch/glazier" rel="noopener noreferrer"&gt;up on GitHub&lt;/a&gt; under MIT. If you've got Go installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/wilhelm-murdoch/glazier/cmd/glaze@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, grab a prebuilt binary from the &lt;a href="https://github.com/wilhelm-murdoch/glazier/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;, checksums and provenance attestations are included. Small note on this, I do plan on adding support for all your favourite package managers to make this process a bit more intuitive. Homebrew first as a test run, then all the other you'd expect.&lt;/p&gt;

&lt;p&gt;If you've opinions on &lt;code&gt;locals&lt;/code&gt;, var files or what Glazier should learn next, the comments are right below.&lt;/p&gt;

&lt;p&gt;P.S. - I've another secret project in the works and I'm using it to declaratively generate Open Graph social cards for the blog. I thought I'd create a format dedicated to Glazier releases.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx69jxwx05whocb0h25sq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx69jxwx05whocb0h25sq.png" alt="The Open Graph release card for Glazier v0.5.1" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://wilhelm.codes" rel="noopener noreferrer"&gt;Generated using HCL and Ogre...&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>hcl</category>
      <category>tmux</category>
      <category>glazier</category>
    </item>
    <item>
      <title>Some Long Overdue Demolition</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Sun, 23 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/some-long-overdue-demolition-27jk</link>
      <guid>https://dev.to/wilhelm-codes/some-long-overdue-demolition-27jk</guid>
      <description>&lt;p&gt;Two months ago I wrote &lt;a href="https://wilhelm.codes/blog/some-long-overdue-housekeeping/" rel="noopener noreferrer"&gt;Some Long Overdue Housekeeping&lt;/a&gt;. A post about giving this blog a proper facelift. I untangled my Tailwind setup, modernised Hugo and taught it to drive the compiler natively and generally felt very pleased with the state of things.&lt;/p&gt;

&lt;p&gt;So, of course just weeks later I have this burning desire to nuke it all from orbit! &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This means no more standalone Tailwind config, no committed stylesheet, no &lt;code&gt;node_modules&lt;/code&gt; in &lt;code&gt;static/&lt;/code&gt; and no build toolchain leaking onto the live site. Very cool!&lt;/p&gt;

&lt;p&gt;— me, in June, admiring a thing I have since bulldozed&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To be clear, the June cleanup wasn't &lt;em&gt;wrong&lt;/em&gt;. The pipeline was genuinely tidy. But, a tidy pipeline is still a pipeline and somewhere between then and now I started asking a more annoying question: why does a personal blog, a pile of static text, need a JavaScript toolchain at all? Why do I need to buy into the Tailwind framework when I knew full well how to write vanilla CSS with my own hands? Every &lt;code&gt;npm install&lt;/code&gt; was a little tax that I'd eventually have to pay with interest the next time I wanted to update the site's dependencies. I felt as if I wasn't maintaining a blog. I was maintaining the machinery that produces a blog and the machinery had some very strong opinions.&lt;/p&gt;

&lt;p&gt;So, I did a hard reset with a &lt;em&gt;very&lt;/em&gt; satisfying amount of deleting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demolition ...
&lt;/h2&gt;

&lt;p&gt;Here's the bill for the redesign commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;230 files changed, 3951 insertions(+), 33365 deletions(-)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's roughly a 10-to-1 ratio of deleted to written. Which, as a software engineer, is my favourite kind of ratio. I happily added the following the to chopping block:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind v4 and the typography plugin.&lt;/strong&gt; Replaced by one hand-written CSS file. The whole thing gzips to under &lt;code&gt;~10kB&lt;/code&gt; with syntax highlighting included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The entire Node toolchain.&lt;/strong&gt; The &lt;code&gt;package.json&lt;/code&gt;, lockfile, Prettier and its plugins. There is no &lt;code&gt;npm install&lt;/code&gt; step anymore because there is no npm anymore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuse.js and the ⌘K search palette.&lt;/strong&gt; Nobody was searching this blog. I checked. It was me. I was nobody.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two self-hosted webfonts.&lt;/strong&gt; More on this in a second, because it's the biggest single win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The changelog page.&lt;/strong&gt; This pulled from the GitHub events API and shipped with an &lt;code&gt;18,016&lt;/code&gt;-line JSON fixture for local dev. Eighteen &lt;em&gt;thousand&lt;/em&gt; lines of someone else's API response, committed to my repo. So, a page barely anybody visited could render in development. I mean, I added it back becase &lt;em&gt;I&lt;/em&gt; missed it, but it's &lt;em&gt;much&lt;/em&gt; more efficient now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;68 of the 79 icon SVGs.&lt;/strong&gt; A 390-line drawer menu and a taxonomy I'd stopped using.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The layouts went from &lt;code&gt;2,868&lt;/code&gt; lines of template across &lt;code&gt;48&lt;/code&gt; files to about &lt;code&gt;800&lt;/code&gt; lines across &lt;code&gt;21&lt;/code&gt;. Not because I got cleverer, but because most of that code was written for the joy of it. Which, look, is a fine reason to build something. It's just a bad reason to keep serving it to strangers. And with all that complexity and cruft came what felt like a lot of brittleness.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fonts had to go too.
&lt;/h2&gt;

&lt;p&gt;This one hurt a little. I like Inter and Rubik. Together they were &lt;code&gt;449kB&lt;/code&gt; of &lt;code&gt;woff2&lt;/code&gt; riding along on every first visit. Which made them, by an enormous margin, the heaviest thing on the site.&lt;/p&gt;

&lt;p&gt;So, I dropped them and switched to the system font stack which, to my surprise, actually looked &lt;em&gt;better&lt;/em&gt; on the site. The actual typeface was doing very little. Your operating system already ships a perfectly good set. All lovingly hinted by people with far more patience and experience than myself. Best of all it costs zero bytes to use!&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;By Grabthar's hammer... What a savings.&lt;/p&gt;

&lt;p&gt;— Dr. Lazarus from Galaxy Quest&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Hugo, finally home alone.
&lt;/h2&gt;

&lt;p&gt;Thanks to all the trimmed fat, the build process is effectively just &lt;code&gt;hugo&lt;/code&gt;. No more extended binary, SCSS, image processing or defining &lt;code&gt;NODE_VERSION&lt;/code&gt; in Cloudflare Pages required. Fewer dependencies mean fewer moving parts and that's always an improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers, or it didn't happen.
&lt;/h2&gt;

&lt;p&gt;Right after the deploy I proudly measured the new site and announced it was "under &lt;code&gt;100kB&lt;/code&gt;". Which would have been true if the production site were served by my dev server, uncompressed, with a &lt;code&gt;40kB&lt;/code&gt; livereload script attached. I made the mistake of measuring the network stats on my local development server. &lt;em&gt;Womp, womp&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The real numbers, from the live site with Cloudflare's &lt;code&gt;brotli&lt;/code&gt; doing its thing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cold home page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~750kB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~24kB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webfonts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;449kB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0kB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stylesheet ( compressed )&lt;/td&gt;
&lt;td&gt;it's complicated&lt;/td&gt;
&lt;td&gt;under &lt;code&gt;~10kB&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;required&lt;/td&gt;
&lt;td&gt;there is nothing to install&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single largest asset on the home page is now the favicon. &lt;code&gt;7.5kB&lt;/code&gt;; roughly a third of the total weight. I think you're allowed to stop optimising when your largest single asset is your &lt;code&gt;favicon.ico&lt;/code&gt;. It's an image of my gorgeous mug. It stays. I'm quite attached to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  An ambitious first pass.
&lt;/h2&gt;

&lt;p&gt;In retrospect, I wanted to make everything simpler and unintentionally over-compensated in the opposite direction. I stripped out too much and was now staring at a sad little blog with zero personality.&lt;/p&gt;

&lt;p&gt;So, the second half of the job was building the personality back and making my selections a bit more deliberate. One thing led to another and I think I've produced my finest work yet on a personal blog. It's small and lightening fast. I'm not sure where else I can optimise without drastically affecting the overall vibe of the design; the law of diminishing returns... returns.&lt;/p&gt;

&lt;p&gt;The thing about now having such a light frame is adding to it stops feeling like a burden, or something future Wilhelm will have to pay. For example, here are the biggest things I've added and they &lt;em&gt;barely&lt;/em&gt; increased the overall "weight" of the design.&lt;/p&gt;

&lt;h3&gt;
  
  
  Series
&lt;/h3&gt;

&lt;p&gt;A proper taxonomy for multi-part writing. Built because I've been wanting to write a Cyberpunk-themed set of tutorials for writing advanced HCL in Go. The series will be about eight posts long and outside of sharing a tag, I had no way to string them together. Series posts wear a stacked-sheet card, a segmented progress meter that is absolutely &lt;em&gt;not&lt;/em&gt; a cyberdeck RAM readout and a panel showing every part in the run, including the ones that don't exist yet.&lt;/p&gt;

&lt;p&gt;The unpublished parts of a series ship as glyph noise and decode, left to right, when you point at them. The real titles sit in &lt;code&gt;aria-label&lt;/code&gt; the whole time, so a screen reader is never handed the static. The browser tab pulls the same trick on every page load, because a title bar is just a very small marquee nobody was using.&lt;/p&gt;

&lt;p&gt;Try moving your mouse pointer over some of the "encrypted" titles on the new &lt;a href="https://wilhelm.codes/series" rel="noopener noreferrer"&gt;dedicated&lt;/a&gt; page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tired LEDs
&lt;/h3&gt;

&lt;p&gt;The lit chips in the series meters, static leaders in the main page's hero section and a scattered handful of cells in the activity graph flicker like hardware that has seen some things. Their animation clocks share no common factors, so the pattern never visibly repeats. The same no-RNG randomness as the heading glitch. On page load the whole graph boots like an old panel, dark, then a sputter per cell, then steady stream of blinking boxes. Reminiscent of the old monochrome screens and beige boxes of my youth. &lt;/p&gt;

&lt;p&gt;Christ, I'm getting old.&lt;/p&gt;

&lt;h3&gt;
  
  
  Changelog, resurrected
&lt;/h3&gt;

&lt;p&gt;Yes, the one from the butcher's block above. However, instead of hitting the associated Github repository directly at build time using a PAT, the new one doesn't call anyone. Commit data is sourced &lt;em&gt;directly&lt;/em&gt; from Cloudflare Pages' shallow clone it checks out during build time. There's a simple 20-or-so-line shell script reads &lt;code&gt;git log&lt;/code&gt; and Hugo renders the result. &lt;code&gt;HEAD&lt;/code&gt; gets a lit node that flickers, &lt;a href="https://wilhelm.codes/changelog" rel="noopener noreferrer"&gt;because why the hell not&lt;/a&gt;?&lt;/p&gt;

&lt;h3&gt;
  
  
  Image shortcodes
&lt;/h3&gt;

&lt;p&gt;Something I've been meaning to add is support for various ways to display images. Each image can now be clicked-to-zoom with attribution in the following forms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Masonry.&lt;/li&gt;
&lt;li&gt;A scrollable, horizontal display.&lt;/li&gt;
&lt;li&gt;Last, but not least, the typicla single image display.
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's actually a sneaky, super-secret page I use to test all my shortcodes and typography stylings where you can &lt;a href="https://wilhelm.codes/blog/markdown-elements/" rel="noopener noreferrer"&gt;see everything for yourself&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Even with all these complex additions, it all adds up to a few kilobytes of CSS and inline script. Which is to say the entire second wave of features weighs an order of magnitude less than one of the webfonts I deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  My new favourite fidget toy.
&lt;/h2&gt;

&lt;p&gt;The part I didn't expect was to glue my eyeballs to the Firefox throttle menu under its network tools panel.&lt;/p&gt;

&lt;p&gt;I flipped it to a 2G profile just for shits and giggles - call it a generous &lt;code&gt;50kb&lt;/code&gt; per second - and did the napkin math. The old site's &lt;code&gt;750kB&lt;/code&gt; works out to between &lt;code&gt;60&lt;/code&gt; to &lt;code&gt;90&lt;/code&gt; seconds of staring at a blank viewport. The new site lands in about four seconds on a connection profile most of the industry stopped thinking about a decade ago.&lt;/p&gt;

&lt;p&gt;I have reloaded this page on simulated garbage networks far more times than any reasonable person should, and it is satisfying &lt;em&gt;every single time&lt;/em&gt;. The page just... appears. Like a website. Remember plain'ole websites?&lt;/p&gt;

&lt;p&gt;None of this is novel, I know. The "your blog doesn't need &lt;code&gt;2MB&lt;/code&gt; of JavaScript" essay has been written a hundred times. Usually on a blog serving &lt;code&gt;2MB&lt;/code&gt; of JavaScript. I'm not even the hundred-and-first person to do this. But, there's a difference between nodding along to the idea and watching your own site load over a simulated tin-can connection before you can finish blinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing ...
&lt;/h2&gt;

&lt;p&gt;The funny thing about the June post is that it ended with me feeling like the blog was finally sorted. And it was! For the architecture it had. It just turned out the architecture itself was the thing that needed to go.&lt;/p&gt;

&lt;p&gt;The site is now boring in the best possible way. It builds with one command, it has no dependencies to rot behind my back and the whole design fits in one stylesheet I can actually hold in my head ( not really ). Fewer moving parts, fewer things to forget, fewer surprises the next time I touch it in six months. Best of all, it looks fucking great!&lt;/p&gt;

&lt;p&gt;Set-and-forget, but this time with almost nothing left to forget. ... Right?&lt;/p&gt;

</description>
      <category>hugo</category>
      <category>css</category>
      <category>performance</category>
      <category>wilhelmcodes</category>
    </item>
    <item>
      <title>It's OK to Let Go</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/its-ok-to-let-go-58fk</link>
      <guid>https://dev.to/wilhelm-codes/its-ok-to-let-go-58fk</guid>
      <description>&lt;p&gt;So, I’m a sucker for wordplay and puns. Anyway…&lt;/p&gt;

&lt;p&gt;There’s a line in my shell history that goes back to 2022: &lt;code&gt;go get github.com/wilhelm-murdoch/go-collection&lt;/code&gt;. For years it was the first dependency into almost every Go project I tinkered with, sometimes before I’d even give the idea a solid shape. I’d open Neovim, initialise a module and then my little collection library got pulled in. Just like muscle memory.&lt;/p&gt;

&lt;p&gt;Today I’m archiving it. Not because it broke and not because I got bored of it, but because the Go standard library quietly walked up and did its job better than it ever could. This post is part explanation and part eulogy, because I think we’re generally quite bad at ending software on purpose and it’s worth practising in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why even write it in the first place?
&lt;/h2&gt;

&lt;p&gt;Waaaay back in 2022 Go 1.18 had just shipped generics after roughly a decade of the community asking, arguing and writing increasingly unhinged &lt;code&gt;interface{}&lt;/code&gt; workarounds. I wanted to actually &lt;em&gt;learn&lt;/em&gt; the new type parameter machinery rather than just read about it and I was also deeply tired of writing the same &lt;code&gt;for&lt;/code&gt; loop to check whether a slice contained a thing for the hundredth time.&lt;/p&gt;

&lt;p&gt;So &lt;a href="https://github.com/wilhelm-murdoch/go-collection" rel="noopener noreferrer"&gt;go-collection&lt;/a&gt; happened. A single generic &lt;code&gt;Collection[T]&lt;/code&gt; type wrapping a slice, with all the conveniences I kept reaching for: &lt;code&gt;Contains&lt;/code&gt;, &lt;code&gt;Find&lt;/code&gt;, &lt;code&gt;Filter&lt;/code&gt;, &lt;code&gt;Map&lt;/code&gt;, &lt;code&gt;Sort&lt;/code&gt;, &lt;code&gt;Batch&lt;/code&gt;, &lt;code&gt;Push&lt;/code&gt;, &lt;code&gt;Pop&lt;/code&gt; and a few dozen friends. All chainable, fluent, tested and small.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There are far more comprehensive modules out there, but this one works quite well for my purposes. &lt;cite&gt;me, in the README, setting expectations appropriately for once&lt;/cite&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence was doing more work than I realised at the time. It wasn’t trying to compete with the big functional-utility libraries. It was scratching a personal itch and for a few years it scratched it well. Then Go’s standard library grew and slurped most of it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking a trip to the morgue.
&lt;/h2&gt;

&lt;p&gt;Cause of death is easy to establish here, because it happened in three well-documented releases. Go 1.21 shipped the &lt;code&gt;slices&lt;/code&gt; package. Go 1.22 and 1.23 finished the job. Let’s walk through it, method by method, like the nerds we are.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it have the thing?
&lt;/h3&gt;

&lt;p&gt;The bread and butter. Probably eighty percent of my actual usage of this library was some flavour of “is this thing in there?”:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;fruits&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"apple"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orange"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"strawberry"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;fruits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orange"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// true&lt;/span&gt;

&lt;span class="n"&gt;fruits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FindIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"str"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c"&gt;// 2&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As of Go 1.21, the standard library does both on a plain slice with no wrapper type in sight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;fruits&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"apple"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orange"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"strawberry"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fruits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orange"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// true&lt;/span&gt;

&lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IndexFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fruits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"str"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c"&gt;// 2&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;New&lt;/code&gt;, no &lt;code&gt;.Items()&lt;/code&gt; to unwrap at the end when some other API wants a real slice. It’s just my function with better ergonomics and a compiler team maintaining it. I’m fine with this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sorting, where I don’t even get to feel bitter.
&lt;/h3&gt;

&lt;p&gt;Here’s my &lt;code&gt;Sort&lt;/code&gt;. I want you to look at what using it actually required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;numbers&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;numbers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;numbers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;At&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;numbers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;At&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Index-based comparators, so you had to reach &lt;em&gt;back into the collection you were currently sorting&lt;/em&gt; to get at the values. I inherited that design from the old &lt;code&gt;sort.Slice&lt;/code&gt; idiom and it was clunky then too. The modern equivalent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;numbers&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when you’re sorting something with actual structure, &lt;code&gt;SortFunc&lt;/code&gt; hands you the two elements directly, with &lt;code&gt;cmp.Compare&lt;/code&gt; doing the boring part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SortFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;people&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Age&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn’t a case of the standard library catching up to my library. It blew my version right out of the water.&lt;/p&gt;

&lt;h3&gt;
  
  
  The killing blow came with iterators.
&lt;/h3&gt;

&lt;p&gt;Go 1.22 added &lt;code&gt;slices.Concat&lt;/code&gt;, which retired my &lt;code&gt;Concat&lt;/code&gt;. Fine, that one was three lines anyway. But Go 1.23 shipped range-over-function iterators and with them &lt;code&gt;slices.Chunk&lt;/code&gt;, and that’s the release where I stopped pretending this was still a contest. &lt;code&gt;Batch&lt;/code&gt; was one of the few methods I was genuinely proud of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="n"&gt;Record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// process item as part of batch N&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it’s a language feature wearing a standard library hat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// chunk is a []Record of up to 100 items&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real &lt;code&gt;for&lt;/code&gt; loop. You can &lt;code&gt;break&lt;/code&gt; out of it, &lt;code&gt;continue&lt;/code&gt; past things and return early. My callback-based version couldn’t do any of that without contorting itself. The trouble is that a wrapper library can only ever be as expressive as the language it wraps and the language just grew.&lt;/p&gt;

&lt;h3&gt;
  
  
  The stuff that never needed a library.
&lt;/h3&gt;

&lt;p&gt;An honest confession while we’re standing over the body: a decent chunk of the API never deserved to exist in the first place. &lt;code&gt;Push&lt;/code&gt;, &lt;code&gt;Pop&lt;/code&gt;, &lt;code&gt;Shift&lt;/code&gt;, &lt;code&gt;Unshift&lt;/code&gt;. I wrote JavaScript for years before Go was ever a public thing and apparently nobody walks away from that unscathed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"e"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// s = append(s, "e")&lt;/span&gt;
&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c"&gt;// item = s[len(s)-1]; s = s[:len(s)-1]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comments on the right are the entire implementation. Idiomatic Go slice handling was already good at this in 2012. Those methods existed because my fingers missed &lt;code&gt;Array.prototype&lt;/code&gt;, not because Go was missing anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The “survivors”, if you could even call them that.
&lt;/h3&gt;

&lt;p&gt;Here’s the wrinkle that makes this more interesting than a straight obituary. &lt;code&gt;Map&lt;/code&gt;, &lt;code&gt;Filter&lt;/code&gt;, &lt;code&gt;Reduce&lt;/code&gt;, &lt;code&gt;Some&lt;/code&gt;, &lt;code&gt;None&lt;/code&gt;; the functional stalwarts &lt;em&gt;still&lt;/em&gt; aren’t in the standard library, all these years later. On paper, that’s the corner of the market my little module could have retired to. A niche! Sustained relevance!&lt;/p&gt;

&lt;p&gt;Except, no. First, because my &lt;code&gt;Map&lt;/code&gt; had a secret shame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="n"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See it? &lt;code&gt;T&lt;/code&gt; in, &lt;code&gt;T&lt;/code&gt; out. Go methods can’t introduce new type parameters, so a method-based &lt;code&gt;Map&lt;/code&gt; can never change the element type. Strings to strings, ints to ints. A &lt;code&gt;Map&lt;/code&gt; that can only map onto itself is a &lt;code&gt;Map&lt;/code&gt; in vibes only; the moment you want lengths of strings you’re back to writing a loop anyway.&lt;/p&gt;

&lt;p&gt;And second, more fundamentally: the Go community collectively shrugged and decided the loop was fine. The proposal discussions happened, the experiments ran and the ecosystem largely settled on this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ducks&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duck&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;ducks&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToUpper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duck&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines. Obvious to anyone who’s read any Go at all, no import, nothing to learn, nothing to maintain and debugger-friendly. Even where &lt;code&gt;go-collection&lt;/code&gt; wasn’t superseded, it turned out to be unnecessary, which is somehow a more thorough defeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Software is a tool, not a legacy.
&lt;/h2&gt;

&lt;p&gt;So that’s the technical half. Here’s the part I actually wanted to write about.&lt;/p&gt;

&lt;p&gt;We don’t really have a culture of &lt;em&gt;finishing&lt;/em&gt; software. A project is either “actively maintained” or it’s “abandoned”, with all the guilt that word drags behind it. Issues accumulate, badges rot and the README slowly becomes a lie because nobody wants to be the person who admits the thing is done, or no longer necessary. I’ve had this module sitting in that limbo for a while now, still getting pulled into new projects out of habit while a strictly better replacement sat in the standard library the whole time.&lt;/p&gt;

&lt;p&gt;But software isn’t a legacy; it’s a tool. Tools serve a purpose. This one’s purpose was to teach me generics in their first year of existence and to spare me a few thousand &lt;code&gt;for&lt;/code&gt; loops while the language sorted itself out. Purpose served on both counts. And of all the ways for a library to die, being absorbed by the standard library is the best one available. It means the gap you filled was real and now it’s just &lt;em&gt;gone&lt;/em&gt;, fixed at the correct layer, for everyone and forever. That’s not a failure state. That’s the mission succeeding so hard the mission ceases to exist.&lt;/p&gt;

&lt;p&gt;Keeping it alive from here would mean maintaining a slightly worse, slightly slower, personally-branded copy of &lt;code&gt;slices&lt;/code&gt; out of pure sentiment. Nobody needs that, least of all me. I’m busy enough and would much rather move on to newer, more interesting things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical bits.
&lt;/h2&gt;

&lt;p&gt;So today, the repo gets the full send-off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A deprecation notice at the top of the README, pointing anyone who lands there at the &lt;a href="https://pkg.go.dev/slices" rel="noopener noreferrer"&gt;slices&lt;/a&gt; package and at this post.&lt;/li&gt;
&lt;li&gt;The repository gets &lt;a href="https://github.com/wilhelm-murdoch/go-collection" rel="noopener noreferrer"&gt;archived&lt;/a&gt; on GitHub. Read-only, clearly signposted and locked in eternal statis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re one of the however-many people with this module in a &lt;code&gt;go.mod&lt;/code&gt; somewhere nothing breaks. The Go module proxy caches published versions more or less permanently, so &lt;code&gt;v1.0.11&lt;/code&gt; will keep resolving long after we’re all dust. Archiving just makes the end of our relationship official rather than leaving it implied.&lt;/p&gt;

&lt;p&gt;If you’ve got a little module of your own in the same situation that has been superseded, purpose spent, kept alive out of habit, then consider this your permission slip. Write the notice, hit archive and go build that next thing. An honest ending is a kindness to your users and to yourself. It’s OK to let go.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/wilhelm-murdoch/glazier/commit/8b6b84a220225fe714b8623aac1b183d64faf09a" rel="noopener noreferrer"&gt;Here&lt;/a&gt; is an example of me tearing &lt;code&gt;go-collection&lt;/code&gt; out of my very recent Glazier project. This one stung a little at how easily it was replaced.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing …
&lt;/h2&gt;

&lt;p&gt;Thanks, little buddy. You were there before the ecosystem knew what generics were supposed to look like, you appeared in more of my &lt;code&gt;go.mod&lt;/code&gt; files than any dependency I didn’t write, and you were, right to the end, exactly as comprehensive as I needed you to be.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;go get&lt;/code&gt; in peace.&lt;/p&gt;

</description>
      <category>projects</category>
      <category>go</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Added Variable Block Support To Glazier</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/i-added-variable-block-support-to-glazier-4lbb</link>
      <guid>https://dev.to/wilhelm-codes/i-added-variable-block-support-to-glazier-4lbb</guid>
      <description>&lt;p&gt;I knew the moment I thought about potentially adding variable definition blocks as a first-class feature Glazier it was going to bother me until I went about implementing it. So, as expected, a week later, here we are. I am happy to report that I've maintained my spotless record of &lt;em&gt;not&lt;/em&gt; talking myself out of something tricky.&lt;/p&gt;

&lt;p&gt;So, over the course of the past few days Glazier learned a new trick. Profiles can now declare the variables they accept, give them primitive types, mark them as required and read them back through a proper &lt;code&gt;var.&lt;/code&gt; namespace. &lt;/p&gt;

&lt;h2&gt;
  
  
  Out with the old.
&lt;/h2&gt;

&lt;p&gt;Technically speaking, Glazier has had variable support since day one. You could pass &lt;code&gt;--var region=watson&lt;/code&gt; and reference &lt;code&gt;${region}&lt;/code&gt; anywhere in your profile and that was that. Anything you passed got dumped into a flat namespace and any name you &lt;em&gt;referenced&lt;/em&gt;, but forgot to set, quietly resolved to an empty string.&lt;/p&gt;

&lt;p&gt;That's fine right up until you fat-finger &lt;code&gt;${reigon}&lt;/code&gt; and spend ten minutes wondering why your session name has a hole in it. The whole reason I built this thing was to get Terraform's "here is exactly what you did wrong and where" experience.&lt;/p&gt;

&lt;p&gt;Letting variables fail silently just felt a bit like an own-goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Profiles can now declare what they accept.
&lt;/h2&gt;

&lt;p&gt;Because this whole thing is kind of a love letter to Terraform's parser, the idea behind variable definitions is functionally the same. You declare a &lt;code&gt;variable&lt;/code&gt; block, it sits at the top level next to your session and that's the input you're allowed to pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"district"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"the district the gig is themed after"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"watson"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"fixer"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gig-${var.district}"&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.fixer}-ops"&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"echo ${var.fixer} has the next job"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, you reach a variable through &lt;code&gt;var.&lt;/code&gt; and &lt;em&gt;only&lt;/em&gt; &lt;code&gt;var.&lt;/code&gt; now, so it reads exactly like it would in a &lt;code&gt;.tf&lt;/code&gt; file. Second, &lt;code&gt;district&lt;/code&gt; has a default and &lt;code&gt;fixer&lt;/code&gt; doesn't, which turns out to matter quite a lot in a second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze up &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;fixer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wakako &lt;span class="c"&gt;# district falls back to "watson"&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze up &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;district&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;arasaka &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;fixer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wakako
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, here you are using the same templated profile to define two seperate sessions decided entirely by flags.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass it something it doesn't know and it says so.
&lt;/h2&gt;

&lt;p&gt;If you pass a &lt;code&gt;--var&lt;/code&gt; for a variable the profile never declared, you no longer get a silent shrug. You get a proper error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Undefined variable

A value for "ghost" was passed with --var, but the profile declares no
variable "ghost". Add a variable "ghost" {} block, or remove the flag.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I went back and forth on this one. Being strict about undeclared flags is the sort of thing that feels pedantic until the day it catches a typo you'd have otherwise chased for half an hour. In this case strict won.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;--var&lt;/code&gt; you can't explain is almost always a mistake, so Glazier now treats it like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  No default means you have to mean it.
&lt;/h2&gt;

&lt;p&gt;A variable &lt;em&gt;without&lt;/em&gt; a default is required. Leave it unset and &lt;code&gt;up&lt;/code&gt; refuses to do anything until you supply it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Missing required variable

  on .glaze line 6, in variable "fixer":
   6: variable "fixer" {

The variable "fixer" has no default, so a value must be supplied with
--var fixer=&amp;lt;value&amp;gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the bit that makes a profile a genuine contract rather than a suggestion. If a layout is useless without a fixer, declare &lt;code&gt;fixer&lt;/code&gt; with no default and the tool will make absolutely sure you provided one before it touches tmux. &lt;/p&gt;

&lt;h2&gt;
  
  
  Proper type-checking.
&lt;/h2&gt;

&lt;p&gt;Every variable declares a &lt;code&gt;type&lt;/code&gt;. Use one of the bare keywords &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt; or &lt;code&gt;bool&lt;/code&gt;. The value you hand over on the command line is coerced into that type and rejected if it doesn't fit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"base_index"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ glaze up --var base_index=two

Error: Invalid variable value

  on .glaze line 1, in variable "base_index":
   1: variable "base_index" {

The value passed for variable "base_index" with --var cannot be used as
number: a number is required.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;two&lt;/code&gt; is not a number, so you find out &lt;em&gt;before&lt;/em&gt; anything launches rather than after tmux has already swallowed a garbage option and started behaving oddly. Booleans get the same treatment: &lt;code&gt;true&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt; are fine, anything else is shown the door. Unfortunately, I decided not to go with truthy or falsey value coercion.&lt;/p&gt;

&lt;p&gt;I kept it to the three primitives on purpose. The moment you add &lt;code&gt;list(string)&lt;/code&gt; and friends you're also signing up for coercing comma-soup off the command line into nested types. That's a much bigger can of worms than a tmux helper needs to open today.&lt;/p&gt;

&lt;p&gt;Though, obviously, if you disagree you're more than welcome to leave a comment below!&lt;/p&gt;

&lt;h2&gt;
  
  
  A namespace for everything.
&lt;/h2&gt;

&lt;p&gt;I'll be honest about the consequence here. This is a breaking change and pretending otherwise would be rude. The old flat &lt;code&gt;${region}&lt;/code&gt; style is gone. If a value comes from a declared variable, you read it as &lt;code&gt;var.region&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which raised a genuinely interesting design question I sat with for a while. If variables are only ever used &lt;em&gt;inside&lt;/em&gt; a session, why not declare them inside the session block too? It feels more intuitive at first though, but it's also wrong. &lt;/p&gt;

&lt;p&gt;However you nest the declaration, the &lt;em&gt;reference&lt;/em&gt; is still flat and global; you write &lt;code&gt;var.fixer&lt;/code&gt;, never &lt;code&gt;session.var.fixer&lt;/code&gt;. Declaring something file-global but reading it block-scoped is incoherent and a variable is really an input to the &lt;em&gt;invocation&lt;/em&gt;, sitting conceptually above the session and not a property of it. So they live at the top level, exactly where Terraform puts them. I borrowed both the syntax and the reasoning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where did the environment variables go?
&lt;/h2&gt;

&lt;p&gt;They also got their own namespace. Previously &lt;code&gt;GLAZE_ENV_token&lt;/code&gt; showed up as a bare &lt;code&gt;${token}&lt;/code&gt;, sharing the same flat namespace as everything else. Now it lives under an &lt;code&gt;env.&lt;/code&gt; namespace, which makes the three sources nicely symmetric:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;var.*&lt;/code&gt; for variables you declared and passed with &lt;code&gt;--var&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;env.*&lt;/code&gt; for &lt;code&gt;GLAZE_ENV_*&lt;/code&gt; environment variables&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;path.pwd&lt;/code&gt; &amp;amp; &lt;code&gt;path.base&lt;/code&gt; the unchanged built-ins
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"host-${env.box}"&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;GLAZE_ENV_box&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nightcity glaze up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;session = host-nightcity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no declaration needed for &lt;code&gt;env.*&lt;/code&gt;; the environment is the environment. Only the things you pass with &lt;code&gt;--var&lt;/code&gt; have to be declared, because those are the inputs &lt;em&gt;you&lt;/em&gt; are claiming the profile accepts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tearing down a session.
&lt;/h2&gt;

&lt;p&gt;If a required variable now blocks &lt;code&gt;up&lt;/code&gt;, doesn't that wreck &lt;code&gt;glaze down&lt;/code&gt;? I recently taught the &lt;code&gt;down&lt;/code&gt; command to evaluate &lt;em&gt;only&lt;/em&gt; the session name so it doesn't need every variable buried deep in the profile just to kill a session. Surely required variables undo that?&lt;/p&gt;

&lt;p&gt;They don't and this was the fiddly bit to get right. &lt;code&gt;down&lt;/code&gt; resolves variables leniently. A variable used solely in some pane command, far away from the session name, is neither required nor evaluated when you're tearing things down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"beep"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"daemon-run"&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"echo ${var.beep}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze down
&lt;span class="gp"&gt;INF nothing to do;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;session is not running &lt;span class="nv"&gt;session&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;daemon-run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;beep&lt;/code&gt; isn't provided, the command just accepts it and moves on, because &lt;code&gt;down&lt;/code&gt; never needed it. &lt;code&gt;up&lt;/code&gt; enforces the full contract; &lt;code&gt;down&lt;/code&gt; only asks for what the name actually depends on. Obviously, the exception here is if a variable is used to construct the actual session name. Other than that, this is an idempotent no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation has been updated too.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;glaze format --validate&lt;/code&gt; decodes a profile and reports diagnostics without ever touching tmux and it now enforces the &lt;em&gt;entire&lt;/em&gt; variable contract. Miss a required variable and validation fails right there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze format &lt;span class="nt"&gt;--validate&lt;/span&gt;
&lt;span class="go"&gt;Error: Missing required variable
&lt;/span&gt;&lt;span class="c"&gt;  ...
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze format &lt;span class="nt"&gt;--validate&lt;/span&gt; &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-east-1
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;clean. nothing to report.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, you can lint a parameterised profile in CI, or just before committing it and know it holds together before you ever try to bring it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  This literally broke all my current profiles.
&lt;/h2&gt;

&lt;p&gt;And I regret nothing. The flat-namespace migration touched pretty much everything; sample profiles, the docs, a pile of tests and all the profiles I actually use day to day. Normally this is the part of a post where I'd sketch out a careful migration path and apologise profusely.&lt;/p&gt;

&lt;p&gt;But, the entire userbase of this tool is almost, as far as I can tell, me. So the migration path is "I'll fix all my profiles tomorrow morning over coffee." &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There is a real freedom in shipping a breaking change to an audience you can fit in a mirror.&lt;/p&gt;

&lt;p&gt;— it me.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To migrate is easy enough, just define your variables and prefix all references with &lt;code&gt;var.&lt;/code&gt;. This &lt;em&gt;is&lt;/em&gt; still currently in beta after all.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing...
&lt;/h2&gt;

&lt;p&gt;Under the hood this turned into a more interesting refactor than I expected. &lt;code&gt;hcldec&lt;/code&gt; wants to decode a whole file body against one spec and it flatly refuses to tolerate a &lt;code&gt;variable&lt;/code&gt; block sitting next to the session it doesn't know about. The trick was to stop handing it the whole file, pull the session block out myself and decode &lt;em&gt;just its body&lt;/em&gt;, which leaves the variable blocks to be gathered and resolved in their own quiet little pass beforehand. There's a tidy &lt;code&gt;PartialContent&lt;/code&gt; implementation in there that was heaps of fun to figure out.&lt;/p&gt;

&lt;p&gt;None of which a tmux wrapper strictly needed, of course. But "strictly needed" stopped being the bar around the time I added a fuzzer. The point was to feel out how Terraform turns a typo into a friendly, located error and now Glazier does a respectable impression of it for variables too. &lt;/p&gt;

&lt;p&gt;I'm still ironing out all the kinks and ensuring I still have an 80% baseline in testing and code coverage, but I'm planning to release this and a few bug fixes and dependency bumps this weekend. Tomorrow-me gets to read this back with fresh eyes and decide how much of it past-me oversold. Very nice.&lt;/p&gt;

&lt;p&gt;Until I merge it in, you can play with the latest release &lt;a href="https://github.com/wilhelm-murdoch/glazier" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And now I'm thinking about adding &lt;code&gt;*.tfvars&lt;/code&gt;-like functionality. Surely, I won't fall for this again?&lt;/p&gt;

</description>
      <category>go</category>
      <category>hcl</category>
      <category>tmux</category>
      <category>glazier</category>
    </item>
    <item>
      <title>Why I Built Glazier</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/why-i-built-glazier-26gi</link>
      <guid>https://dev.to/wilhelm-codes/why-i-built-glazier-26gi</guid>
      <description>&lt;p&gt;I live in &lt;a href="https://github.com/tmux/tmux/wiki" rel="noopener noreferrer"&gt;tmux&lt;/a&gt;. I typically have an editor here, a dev server there, logs tailing in the corner or a spare pane for poking at things. The trouble is that this little world is frustratingly ephemeral. Rebooting my machine ( thank you, compuslory MacOS updates ), kill the wrong session or just close the laptop lid for too long and it can all evaporate. Then, I'm back to rebuilding the same layout by hand, one &lt;code&gt;split-window&lt;/code&gt; at a time, like some kind of animal.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/wilhelm-murdoch/glazier" rel="noopener noreferrer"&gt;Glazier&lt;/a&gt;; a small command-line tool that lets me describe a tmux workspace once and recreate it on demand. Type &lt;code&gt;glaze up&lt;/code&gt; and the sessions, windows and panes I described spring back into existence exactly how I left them.&lt;/p&gt;

&lt;p&gt;This has been a slow-burning labour of love for the better part of two years and it's finally in a state where I feel comfortable letting other people look at it. So let's talk about why it exists, what else is out there and how this one is different.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's just a config file, right?
&lt;/h2&gt;

&lt;p&gt;That was the idea, at least. I just wanted to stop rebuilding the same layouts over and over. But, as is tradition, I didn't want to make it &lt;em&gt;too&lt;/em&gt; easy for myself.&lt;/p&gt;

&lt;p&gt;There was a second, more selfish motivation. As a platform engineer, there isn't a day that goes by where I don't work with &lt;a href="https://www.terraform.io/" rel="noopener noreferrer"&gt;Terraform&lt;/a&gt;. I've always been quietly fascinated by how it parses and validates its configuration. That whole experience of getting a precise, friendly error pointing at the exact line you fat-fingered, rather than a stack trace and a 🖕. I wanted to understand how that machinery actually worked.&lt;/p&gt;

&lt;p&gt;So, I'm a heavy tmux user &lt;em&gt;and&lt;/em&gt; I wanted to learn HCL parsing from the inside. These two things lined up a little too perfectly. So Glazier's profiles aren't YAML; they're &lt;a href="https://github.com/hashicorp/hcl" rel="noopener noreferrer"&gt;HCL&lt;/a&gt;. The same configuration language Terraform uses and parsed with the same underlying library.&lt;/p&gt;

&lt;p&gt;Here's a basic profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"daemon-run"&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ice-breaker"&lt;/span&gt;
    &lt;span class="nx"&gt;layout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main-vertical"&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"nvim ./payloads"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"watch -n1 netwatch --target arasaka-mainframe"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop that in a file called &lt;code&gt;.glaze&lt;/code&gt;, run &lt;code&gt;glaze up&lt;/code&gt; next to it and you're jacked in.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'm not the first to do this; not even the 3rd... or the 4th.
&lt;/h2&gt;

&lt;p&gt;This is a thoroughly-solved problem and I'd be doing you a disservice if I pretended otherwise. There's a whole shelf of mature, battle-tested tools that do effectively the same thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/tmuxinator/tmuxinator" rel="noopener noreferrer"&gt;tmuxinator&lt;/a&gt; is the one most are familiar with. Written in Ruby with YAML profiles. Probably what most people reach for.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/remi/teamocil" rel="noopener noreferrer"&gt;teamocil&lt;/a&gt; is also written Ruby; also uses YAML.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ivaaaan/smug" rel="noopener noreferrer"&gt;smug&lt;/a&gt; is written in Go and uses YAML and is the closest in spirit to Glazier if we're being honest.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/tmux-python/tmuxp" rel="noopener noreferrer"&gt;tmuxp&lt;/a&gt; is written in good'ole reliable Python and it'll happily eat YAML &lt;em&gt;or&lt;/em&gt; JSON.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already use and trust one of these, I'll be straight with you: there isn't a compelling reason to switch. Keep using what works. I'm not here to convince anyone to rip out a tool they're happy with.&lt;/p&gt;

&lt;p&gt;But if you're still reading, here's what &lt;em&gt;I&lt;/em&gt; like about mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what's actually different?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The profile validates itself!
&lt;/h3&gt;

&lt;p&gt;This is the part I set out to build, so it's the part I'm fondest of. Because Glazier is built on HCL, it inherits Terraform-style diagnostics for free. Mistype a layout, point a starting directory at somewhere that doesn't exist or forget a required block and you don't get a vague "something went wrong fuck you". You get told exactly what and where you messed up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Invalid layout specified

  on .glaze line 4, in session.window:
   4:     layout = "main-plumbus"

The layout value of "main-plumbus" is not a supported preset
(even-horizontal, even-vertical, main-horizontal, main-vertical,
tiled) nor a valid tmux layout string.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a &lt;code&gt;glaze format&lt;/code&gt; command that rewrites your profile into a canonical style and, with &lt;code&gt;--validate&lt;/code&gt;, reports any of these diagnostics without touching tmux at all. Both of these scratch exactly the itch that started the whole project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Variables, templates and string functions! Oh, my!
&lt;/h3&gt;

&lt;p&gt;A static layout is useful. A &lt;em&gt;templated&lt;/em&gt; one is better. Profiles can reference variables and you can feed those in from &lt;code&gt;--var&lt;/code&gt; flags or &lt;code&gt;GLAZE_ENV_*&lt;/code&gt; environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ops-${region}"&lt;/span&gt;
  &lt;span class="nx"&gt;starting_directory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pwd&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"k9s --context ${region}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze up &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ap-southeast-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look familiar? If you work with Terraform it does!&lt;/p&gt;

&lt;p&gt;There's a handful of built-in string functions too. &lt;code&gt;upper&lt;/code&gt;, &lt;code&gt;lower&lt;/code&gt;, &lt;code&gt;replace&lt;/code&gt;, &lt;code&gt;trimspace&lt;/code&gt;, &lt;code&gt;join&lt;/code&gt; and friends which act as thin wrappers over the same &lt;code&gt;go-cty&lt;/code&gt; standard library Terraform uses. Plus, a couple of freebies like &lt;code&gt;path.pwd&lt;/code&gt; and &lt;code&gt;path.base&lt;/code&gt; so a profile can adapt to wherever it's run from.&lt;/p&gt;

&lt;h3&gt;
  
  
  It doesn't lie to you about timing.
&lt;/h3&gt;

&lt;p&gt;This is the bit of engineering I'm quietly proudest of, even though nobody will ever see it. When you fire a sequence of commands into a tmux pane, the naive approach is to blast them in with a &lt;code&gt;sleep&lt;/code&gt; between each one and hope the previous command finished. That's flaky and it's how a few other tools handle it.&lt;/p&gt;

&lt;p&gt;Glazier instead serialises commands through tmux's own &lt;a href="https://man.openbsd.org/tmux#wait-for" rel="noopener noreferrer"&gt;&lt;code&gt;wait-for&lt;/code&gt;&lt;/a&gt; signalling, so each command genuinely waits for the previous one to finish before the next is sent. There are no fixed sleeps and no races. The &lt;em&gt;one&lt;/em&gt; exception is the final command in a list, which is sent fire-and-forget. If your last command is a long-running dev server or &lt;code&gt;tail -f&lt;/code&gt;, waiting on it would hang &lt;code&gt;up&lt;/code&gt; forever.&lt;/p&gt;

&lt;p&gt;I learned that last part the hard way.&lt;/p&gt;

&lt;h3&gt;
  
  
  It can &lt;em&gt;mostly&lt;/em&gt; save a session!
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;glaze save&lt;/code&gt; inside a live session and it'll capture the structure back out into a profile: windows, panes, names, layouts, focus and starting directories. The "mostly" is doing some load-bearing work in that sentence and it's a deliberate choice, not a missing feature.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;save&lt;/code&gt; will &lt;strong&gt;not&lt;/strong&gt; export your pane commands, environment variables, hooks or tmux options. Why? Because each of those is a footgun:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; &lt;strong&gt;Commands&lt;/strong&gt; would re-execute on the next &lt;code&gt;up&lt;/code&gt;. A forgotten &lt;code&gt;rm -rf&lt;/code&gt; captured from some pane could ruin your whole day on replay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment variables&lt;/strong&gt; can only be read as the &lt;em&gt;entire&lt;/em&gt; session environment. This means inherited secrets, tokens and keys getting written into a file you might commit. That's a big fat no from me, dawg.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Options&lt;/strong&gt; read back as effective state, hopelessly tangling up with your &lt;code&gt;tmux.conf&lt;/code&gt; and manual tweaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, a saved profile is a scaffold. It gets the geometry right and you add back the commands and config you actually want by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real profile.
&lt;/h2&gt;

&lt;p&gt;Enough talk. Here's the profile I actually use when working this website:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;

  &lt;span class="nx"&gt;envs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;HUGO_GITHUB_TOKEN&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${github}"&lt;/span&gt;
    &lt;span class="nx"&gt;HUGO_ACTIVITY_GRAPH_DEMO&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${demo}"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"editor"&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"nvim"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terminal"&lt;/span&gt;
    &lt;span class="nx"&gt;focus&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"server"&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hugo server --disableFastRender"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt;

    &lt;span class="nx"&gt;pane&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"lazygit"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;glaze up --var github=*** --var demo=true&lt;/code&gt; gives me the following windows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My editor of choice; NeoVim.&lt;/li&gt;
&lt;li&gt;A dedicated terminal session.&lt;/li&gt;
&lt;li&gt;The Hugo server along with some &lt;code&gt;HUGO_&lt;/code&gt; specific environment variables; values sourced from the CLI.&lt;/li&gt;
&lt;li&gt;Finally, &lt;code&gt;lazygit&lt;/code&gt; to manage and commit my changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Further, the &lt;code&gt;--var&lt;/code&gt; arguments aren't really necessary as you could just &lt;code&gt;export GLAZE_var_name&lt;/code&gt; as well and Glazier will pick it up automatically.&lt;/p&gt;

&lt;p&gt;Anyways, because the session is just a named thing, the rest of the lifecycle is tidy too. List what's running with &lt;code&gt;glaze ls&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze &lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;NAME      WINDOWS  PATH
glazier*  2        /home/wilhelm/Development/wilhelm.codes
scratch   1        /tmp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The asterisk marks the session I'm currently attached to. When I'm done, I tear it down by profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;glaze down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;down&lt;/code&gt; is idempotent and that's on purpose. Bringing down a session that isn't running is a no-op, not an error, so it's safe to drop in scripts without defensive checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some over-engineered bits ...
&lt;/h2&gt;

&lt;p&gt;To be frank a tool that shells out to tmux did not strictly &lt;em&gt;need&lt;/em&gt; a fuzzed HCL parser, build provenance attestations on its release binaries or a CI pipeline that runs the test suite against multiple Go versions and operating systems.&lt;/p&gt;

&lt;p&gt;But, that was never really the point. The point was learning how Terraform's parser ticks, working out how to drive tmux reliably without sleeps and over-engineering the ever-loving-shit out of an already-solved problem because it was &lt;em&gt;fun&lt;/em&gt;. Every constraint I imposed upon myself taught me something new. This is ultimately the only metric I actually care about for a hobby project like this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to get it?
&lt;/h2&gt;

&lt;p&gt;It's &lt;a href="https://github.com/wilhelm-murdoch/glazier" rel="noopener noreferrer"&gt;up on GitHub&lt;/a&gt; with a shiny MIT license. If you've got Go installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/wilhelm-murdoch/glazier/cmd/glaze@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, grab a prebuilt binary from the &lt;a href="https://github.com/wilhelm-murdoch/glazier/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;. I currently build for Linux and macOS on both &lt;code&gt;amd64&lt;/code&gt; and &lt;code&gt;arm64&lt;/code&gt;. Each one ships with a checksum and a signed provenance attestation, because of course it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing ...
&lt;/h2&gt;

&lt;p&gt;I would confidently say Glazier has moved on from "experimental" to "stable". It works and I use it every single day. But, there are rough edges and there may be any number of unencountered failure modes. The &lt;code&gt;down&lt;/code&gt; and &lt;code&gt;ls&lt;/code&gt; commands only landed recently and I'm not sure if the latter should remain. I've also got a running list of ideas I haven't talked myself out of yet. Like, something similar to Terraform's &lt;code&gt;*.tfvars&lt;/code&gt; files or defining typed variables.&lt;/p&gt;

&lt;p&gt;If you're already happy with Tmuxinator or Smug, then stick with them. But if the idea of a declarative, self-validating, slightly-too-clever tmux profile appeals to you, or you just want to read some Go that wraps tmux in ways it was probably never meant to be wrapped, I'd love for you to take it for a spin.&lt;/p&gt;

&lt;p&gt;I sincerely hope you find &lt;a href="https://github.com/wilhelm-murdoch/glazier" rel="noopener noreferrer"&gt;Glazier&lt;/a&gt; as useful as I had fun building it.&lt;/p&gt;

</description>
      <category>go</category>
      <category>tmux</category>
      <category>hcl</category>
      <category>glazier</category>
    </item>
    <item>
      <title>Let Me Show You My Bits</title>
      <dc:creator>Wilhelm Murdoch</dc:creator>
      <pubDate>Thu, 18 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/wilhelm-codes/let-me-show-you-my-bits-3j35</link>
      <guid>https://dev.to/wilhelm-codes/let-me-show-you-my-bits-3j35</guid>
      <description>&lt;p&gt;A while back, in &lt;a href="https://wilhelm.codes/blog/reading-between-the-posts/" rel="noopener noreferrer"&gt;Reading Between the Posts&lt;/a&gt;, I wired up the home page so the quiet gaps between articles would narrate themselves - "&lt;em&gt;1 bit and 41 changelog events in between&lt;/em&gt;". There was just one tiny problem with that sentence. At the time I had written exactly &lt;em&gt;zero&lt;/em&gt; bits. &lt;/p&gt;

&lt;p&gt;So let me finally show you my bits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What even is a bit?
&lt;/h2&gt;

&lt;p&gt;A bit is the smallest unit of "I made a thing" I'm willing to commit to. It could be a quote that stuck with me, a link worth keeping, the odd YouTube video, or a one-line thought too small to earn its own long-form entry. Each one is a little markdown file in &lt;code&gt;/bits&lt;/code&gt; and  the only thing it really has to declare is what &lt;em&gt;kind&lt;/em&gt; of bit it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quote&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-06-17&lt;/span&gt;
&lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Edsger&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Dijkstra"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Simplicity&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;great&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;virtue&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;but&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;it&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;requires&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hard&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;work&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;achieve&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;it."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;type&lt;/code&gt; is the whole trick that everything else hangs off.&lt;/p&gt;

&lt;h2&gt;
  
  
  One shape per thought.
&lt;/h2&gt;

&lt;p&gt;The thing I didn't want was for a pithy quote to render like a bare link or to render like a video. They're different shapes of thought, so they ought to look different.&lt;/p&gt;

&lt;p&gt;I'd already learned this lesson building the &lt;a href="https://wilhelm.codes/blog/a-changelog-that-builds-itself/" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;, which leans on one tiny partial per event type. So I stole from myself. There's a single lookup table mapping each &lt;code&gt;type&lt;/code&gt; to an icon, an accent colour and a label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dict&lt;/span&gt;
  &lt;span class="s"&gt;"quote"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dict&lt;/span&gt; &lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="s"&gt;"quote-right"&lt;/span&gt;    &lt;span class="s"&gt;"colour"&lt;/span&gt; &lt;span class="s"&gt;"rose"&lt;/span&gt;    &lt;span class="s"&gt;"label"&lt;/span&gt; &lt;span class="s"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="s"&gt;"link"&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dict&lt;/span&gt; &lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="s"&gt;"arrow-up-right"&lt;/span&gt; &lt;span class="s"&gt;"colour"&lt;/span&gt; &lt;span class="s"&gt;"blue"&lt;/span&gt;    &lt;span class="s"&gt;"label"&lt;/span&gt; &lt;span class="s"&gt;"link"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="s"&gt;"video"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dict&lt;/span&gt; &lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="s"&gt;"youtube"&lt;/span&gt;        &lt;span class="s"&gt;"colour"&lt;/span&gt; &lt;span class="s"&gt;"red"&lt;/span&gt;     &lt;span class="s"&gt;"label"&lt;/span&gt; &lt;span class="s"&gt;"video"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="s"&gt;"blurb"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dict&lt;/span&gt; &lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="s"&gt;"pen-nib"&lt;/span&gt;        &lt;span class="s"&gt;"colour"&lt;/span&gt; &lt;span class="s"&gt;"emerald"&lt;/span&gt; &lt;span class="s"&gt;"label"&lt;/span&gt; &lt;span class="s"&gt;"blurb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then matches a micro-partial per type that decides how the content actually renders. Adding a brand new kind of bit is now "add one row, add one tiny template", which is about as many steps as I'm willing to tolerate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figaehq6gkockvkm96tbf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figaehq6gkockvkm96tbf.png" width="799" height="647"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hugo, why are you like this?
&lt;/h2&gt;

&lt;p&gt;My bits started life with two perfectly reasonable front-matter fields: &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt;. Hugo took one look and quietly robbed me of both.&lt;/p&gt;

&lt;p&gt;It turns out &lt;code&gt;url&lt;/code&gt; is a &lt;em&gt;reserved&lt;/em&gt; key which Hugo uses to treat as the page's permalink. So, the moment I put a &lt;code&gt;https://...&lt;/code&gt; in there, the build fell over with a stern little message about unsupported protocols. &lt;code&gt;type&lt;/code&gt; doesn't even have the decency to error. It silently moves out of &lt;code&gt;.Params.type&lt;/code&gt; and into &lt;code&gt;.Type&lt;/code&gt;, which &lt;em&gt;also&lt;/em&gt; hijacks the layout lookup. So my &lt;code&gt;.Params.type&lt;/code&gt; was resolving to nothing and every single bit was quietly rendering as the fallback.&lt;/p&gt;

&lt;p&gt;Turns out hte fix for this was simply to rename &lt;code&gt;url&lt;/code&gt; to &lt;code&gt;link&lt;/code&gt; and  read &lt;code&gt;.Type&lt;/code&gt; instead of &lt;code&gt;.Params.type&lt;/code&gt;. Half an hour of my life dedicated to two words I didn't know were already spoken for.&lt;/p&gt;

&lt;p&gt;Coding is my passion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bits flapping in the wind.
&lt;/h2&gt;

&lt;p&gt;Rather than dumping bits into the same fold-out list as the GitHub noise, I pulled them out as their own first-class timeline rows. The lower-signal changelog chatter stays tucked behind the "&lt;em&gt;N changelog events in between&lt;/em&gt;" disclosure. So, the home page now reads in three tiers: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the loud headline articles&lt;/li&gt;
&lt;li&gt;the medium-volume bits&lt;/li&gt;
&lt;li&gt;the quiet changelog hum &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same interstitial machinery I built last time, but bits just get to stand a little taller than a commit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffq9o3z5cp2r188wql3il.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffq9o3z5cp2r188wql3il.png" width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The tag that went nowhere.
&lt;/h2&gt;

&lt;p&gt;Bits are deliberately &lt;code&gt;render: never&lt;/code&gt; in Hugo. They live inline on the bits page and never get their own URL. Except "no page" also quietly means "no place in the taxonomy", which means a tag only ever used by a bit has no &lt;code&gt;/tags/...&lt;/code&gt; page to point at. If I link it anyway I've shipped a &lt;code&gt;404&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So, the tags only become links if there's actually somewhere to go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetPage&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;printf&lt;/span&gt; &lt;span class="s"&gt;"/tags/%s"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlize&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://wilhelm.codes/blog/let-me-show-you-my-bits/{{ .RelPermalink }}"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Term&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tag with a real page becomes a link and a bit-only tag stays as plain text. No broken links and the nice part is it heals itself the moment a proper article picks up the same tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing ...
&lt;/h2&gt;

&lt;p&gt;The front page has quietly turned into a proper little stream. Long-form when I've actually got something to say, bits when I don't and  a &lt;a href="https://wilhelm.codes/blog/a-year-in-circles/" rel="noopener noreferrer"&gt;year of squircles&lt;/a&gt; up top keeping a tally of it all.&lt;/p&gt;

&lt;p&gt;Which is the entire point. The less effort it takes to post a half-formed thought, the more likely I am to actually post it. And the more I post, the more those gaps fill in.&lt;/p&gt;

&lt;p&gt;And, as always, you can check everything out for yourself in the &lt;a href="https://github.com/wilhelm-murdoch/wilhelm.codes" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>hugo</category>
      <category>css</category>
      <category>tailwindcss</category>
    </item>
  </channel>
</rss>
