<?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: Vineeth N K</title>
    <description>The latest articles on DEV Community by Vineeth N K (@vineethnkrishnan).</description>
    <link>https://dev.to/vineethnkrishnan</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3779538%2Fca113f9c-3e87-42e1-873f-0a0bc6e7ed57.png</url>
      <title>DEV Community: Vineeth N K</title>
      <link>https://dev.to/vineethnkrishnan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vineethnkrishnan"/>
    <language>en</language>
    <item>
      <title>I went on a trip. My Mac mini stayed home and kept texting me.</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sat, 30 May 2026 17:50:59 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/i-went-on-a-trip-my-mac-mini-stayed-home-and-kept-texting-me-1ejg</link>
      <guid>https://dev.to/vineethnkrishnan/i-went-on-a-trip-my-mac-mini-stayed-home-and-kept-texting-me-1ejg</guid>
      <description>&lt;h1&gt;
  
  
  I went on a trip. My Mac mini stayed home and kept texting me.
&lt;/h1&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%2Fvineethnk.in%2Fblog%2Fthe-mac-mini-that-kept-texting-me-hero.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%2Fvineethnk.in%2Fblog%2Fthe-mac-mini-that-kept-texting-me-hero.png" alt="A young South Asian man relaxing on a sunny hotel balcony looking at his phone, which glows with little notification bells, while a small Mac mini sits glowing back home inside a thought bubble with tiny green status icons floating around it, soft editorial illustration, warm pastel colors." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: A while back I built a homelab on an old 2018 Mac mini. Then I went out of town for a few days and left it running. I half expected to come back to a dead box. Instead it just kept doing its job, let me SSH in from my phone and keep pushing my own CLI tools forward while away, and buzzed me whenever something mattered. Nothing dramatic happened. And honestly, that quiet was the whole point. This is the story of the homelab finally earning its keep while I was nowhere near it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The part nobody tells you about building a homelab
&lt;/h2&gt;

&lt;p&gt;When you set up a homelab, all the blog posts stop at the setup. The screenshots are green, the containers are up, you take your victory lap and close the laptop.&lt;/p&gt;

&lt;p&gt;I did the same. I wrote down &lt;a href="https://vineethnk.in/blog/mac-mini-homelab-setup" rel="noopener noreferrer"&gt;the whole long evening of building this thing&lt;/a&gt;, every gotcha, every GUI click macOS forced on me. At the end I had Vaultwarden, ntfy, Uptime Kuma, n8n, a little agent webhook, restic backups, all sitting on a Mac mini that a colleague handed me from his drawer.&lt;/p&gt;

&lt;p&gt;But here is the thing. A homelab that only works while you are sitting next to it is just a noisy space heater. The real test is the day you are not there. The day the power could flicker, a container could die, a backup could fail, and you would have no idea unless the box itself told you.&lt;/p&gt;

&lt;p&gt;So when a short trip came up, I did not shut anything down. I left it all running and went.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day one, and the silence was loud
&lt;/h2&gt;

&lt;p&gt;First evening away, I caught myself doing the thing. You know the thing. Opening the phone to check if home is still alive, the way you check if you locked the front door.&lt;/p&gt;

&lt;p&gt;I pulled up the status page. Everything green. Uptime Kuma sitting there with a row of happy little dots, every service responding, the agent webhook answering its health check. Netdata showing the mini idling cool and bored.&lt;/p&gt;

&lt;p&gt;And then I just... put the phone down. There was nothing to do. The box did not need me.&lt;/p&gt;

&lt;p&gt;That feeling is strange the first time. You build a thing for months, you babysit it, and then one day it does not need babysitting anymore. Bittersweet, almost. Like dropping a kid at hostel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3:30 buzz
&lt;/h2&gt;

&lt;p&gt;My restic backup runs every night at 03:30 in the morning, back home. Nobody is awake for that, which is the whole idea of a 3:30 AM cron. You set it for the dead of night precisely so it never gets in your way.&lt;/p&gt;

&lt;p&gt;The job fired while I was fast asleep, exactly like it does on any normal night. The only difference was that this night I was not home. I woke up the next morning, picked up the phone out of pure habit, and there it was waiting on the lock screen. ntfy notification. Backup done, snapshot pushed, a few MB in, almost nothing out after dedup.&lt;/p&gt;

&lt;p&gt;A tiny push telling me my data was safe, fired by a machine sitting alone in an empty flat, patiently waiting for me to wake up and read it. I did not do anything. I did not even open the app fully. I just saw it, nodded, and went to find coffee.&lt;/p&gt;

&lt;p&gt;That little buzz is the entire reason I wired ntfy in the first place. Not to spam me. To tell me the boring good news so that the day it becomes bad news, I notice immediately. A backup that runs silently is a backup you do not trust. A backup that texts you "done" every night is one you forget about, in the good way.&lt;/p&gt;

&lt;p&gt;If you have ever felt a small flush of pride at a green cron job, you and I would get along just fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual work happened from my phone
&lt;/h2&gt;

&lt;p&gt;Now the part I am quietly proud of.&lt;/p&gt;

&lt;p&gt;Here is what surprised me. The trip was not me firefighting a homelab from a hotel room. The box was calm the whole time. What I actually did was use the days to work on some cool side stuff and refine a few of my own personal CLI tools, straight from my phone.&lt;/p&gt;

&lt;p&gt;The trick is nothing fancy. Remote Login is on, the mini is on my tailnet, so I open an SSH app on my phone and I am in a real shell on the machine back home. Not a watered-down dashboard, the actual terminal, with my dotfiles, my aliases, my tools, all sitting exactly where I left them. From there I run whatever I want, &lt;code&gt;claude&lt;/code&gt; included, and do real work.&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;# from the phone, over Tailscale&lt;/span&gt;
ssh mac-mini
&lt;span class="c"&gt;# and then just... work, same as if I was at the desk&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the rhythm of my day became this. Find a quiet half hour, SSH in from the phone, run a command, kick off a change to one of my CLI tools, read the output right there on the small screen, run the next one. Tiny keyboard, yes, and I am not going to pretend a phone replaced my full setup. But for steadily nudging a few personal tools forward, command by command, it genuinely worked. I came home with actual progress, not just a tan.&lt;/p&gt;

&lt;p&gt;And yes, the homelab also has that agent webhook. But that one is built for a different job, automating the repetitive tasks from my daily work, where I fire a prompt and let the mini run it on its own and ping me the result. The trip work was the hands-on kind, just done through a very small keyboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing went wrong, and that was the point
&lt;/h2&gt;

&lt;p&gt;Here is the anticlimax. The dashboard stayed green the entire time.&lt;/p&gt;

&lt;p&gt;No service fell over. No 3 AM page. No frantic debugging from a six-inch screen. Uptime Kuma just sat there with its happy row of dots, day after day, and the only buzzes I got were the friendly kind, backup done, agent result ready.&lt;/p&gt;

&lt;p&gt;And I want to be clear that the quiet is not a boring detail to skip past. The quiet is the product. The point of all the monitoring was never to give me a dramatic save story. It was so that if anything did go red, I would know within a heartbeat instead of finding out days later, back home, staring at a dead service with no idea how long it had been gone. I had recovery alerts wired alongside the down alerts too, so a blip would have buzzed me twice, once for the scare and once for the all-clear.&lt;/p&gt;

&lt;p&gt;It just never had to. And honestly, a homelab that gives you a boring trip is the homelab working exactly as designed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I was most nervous about
&lt;/h2&gt;

&lt;p&gt;Power.&lt;/p&gt;

&lt;p&gt;The one fear I could not fully shake was a power cut at home while I was away. If the mini went down and stayed down, my whole little world would go dark and there would be absolutely nothing I could do about it from out of town.&lt;/p&gt;

&lt;p&gt;So I had stacked two layers of insurance for exactly this.&lt;/p&gt;

&lt;p&gt;The first is a power backup. The mini sits behind a UPS that can keep it running on its own for a good six to eight hours. Most power cuts where I live are the short, annoying kind, gone and back before you finish complaining about them. The UPS swallows all of those without the mini ever noticing a thing.&lt;/p&gt;

&lt;p&gt;The second layer is for when a cut outlasts the battery, or when power drops and returns while I am away. Back during setup I had told macOS to bring itself back on its own.&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="nb"&gt;sudo &lt;/span&gt;pmset &lt;span class="nt"&gt;-a&lt;/span&gt; autorestart 1   &lt;span class="c"&gt;# come back on your own after a power cut&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;pmset &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;0         &lt;span class="c"&gt;# and never, ever go to sleep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;autorestart 1&lt;/code&gt; means if power drops and later returns, the Mac boots itself without anyone pressing the button. Colima starts on boot through launchd, the containers come up with &lt;code&gt;restart: unless-stopped&lt;/code&gt;, Tailscale reconnects on its own, and the whole stack reassembles itself like nothing happened.&lt;/p&gt;

&lt;p&gt;Between the two, the only way I genuinely lose is a power cut that runs longer than the battery and then never comes back for the rest of the trip. That is the real dark side, the one scenario where there is nothing left to do but wait until I am home. But it is a narrow window now, not the wide-open fear it used to be. And knowing that let me actually enjoy the trip instead of refreshing a status page every hour. A homelab you have to worry about is not a homelab, it is a pet that bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this trip actually taught me
&lt;/h2&gt;

&lt;p&gt;I came back home, walked in, and the mini was sitting there with its little light on, exactly as I left it. No drama, no recovery saga, no horror story. It had just quietly done its job the entire time.&lt;/p&gt;

&lt;p&gt;And that is the lesson. The point of all that setup, all those gotchas and GUI clicks and one-word Caddy fixes, was not to have a pretty dashboard. It was to be able to leave, fully, and trust the thing to behave and to speak up only when it mattered.&lt;/p&gt;

&lt;p&gt;A few things made that trust possible, and if you are building your own, these are the ones that earned their place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ntfy for the boring good news, not just the bad.&lt;/strong&gt; Let it tell you the backup worked. The day it says the backup failed, you will already be in the habit of reading it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale so the box is in your pocket.&lt;/strong&gt; Everything reachable like it is on localhost, from anywhere, no ports open to the internet. That single choice is what makes the phone a real remote control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uptime Kuma with recovery alerts on too.&lt;/strong&gt; Wire both the down and the all-clear, so the day something blips you get the relief buzz right after the scare, not just the scare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pmset autorestart for the power fear.&lt;/strong&gt; You cannot fix a dead box from another city. So make sure it un-deads itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain SSH from the phone, over Tailscale.&lt;/strong&gt; This is the one that surprised me. A real shell on the home machine, my own tools and dotfiles, reachable from a phone anywhere. It turned dead travel time into actual progress, command by command.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The homelab stopped being a project the day I could walk away from it. Funny how you only really finish building something when you stop having to look at it.&lt;/p&gt;

&lt;p&gt;So tell me, what is the one thing your setup does while you sleep that quietly makes you trust it? I am genuinely curious, because that small thing is usually the whole game.&lt;/p&gt;

&lt;p&gt;Right, I am off to check my phone for no reason again. Old habits. Take care of your machines, and they will take care of you back.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>macmini</category>
      <category>ntfy</category>
      <category>tailscale</category>
    </item>
    <item>
      <title>Building vaultctl: the password vault where my own server can't read your passwords</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sun, 24 May 2026 14:14:40 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/building-vaultctl-the-password-vault-where-my-own-server-cant-read-your-passwords-15em</link>
      <guid>https://dev.to/vineethnkrishnan/building-vaultctl-the-password-vault-where-my-own-server-cant-read-your-passwords-15em</guid>
      <description>&lt;h1&gt;
  
  
  Building vaultctl: the password vault where my own server can't read your passwords
&lt;/h1&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%2Fvineethnk.in%2Fblog%2Fvaultctl-hero.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%2Fvineethnk.in%2Fblog%2Fvaultctl-hero.png" alt="A glowing safe inside a server rack with a small key floating above it, a faded developer silhouette in the background, flat illustration in cool blue and teal tones." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every password manager forces the same uncomfortable question: &lt;em&gt;do I trust this server with my actual passwords?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For most vaults, cloud or self-hosted, the honest answer is "yes, you have to". The server holds the keys. It can decrypt your data. Cloud just makes it worse because the server is not even yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vaultctl&lt;/strong&gt; is what I built when I stopped wanting to make that trade. Self-hosted password vault, but the server has no code path to decrypt anything. Encryption happens in the browser, the extension, or the CLI, before the bytes ever leave the client. If someone walks off with the database tomorrow, what they get is noise.&lt;/p&gt;

&lt;p&gt;This is another post in the series where I walk through my open-source projects. Earlier ones covered &lt;a href="https://vineethnk.in/blog/building-backupctl" rel="noopener noreferrer"&gt;backupctl&lt;/a&gt;, &lt;a href="https://vineethnk.in/blog/building-agent-sessions" rel="noopener noreferrer"&gt;agent-sessions&lt;/a&gt;, and &lt;a href="https://vineethnk.in/blog/building-mcp-pool" rel="noopener noreferrer"&gt;mcp-pool&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I got here
&lt;/h2&gt;

&lt;p&gt;I gave LastPass a real try in the early days, only with throwaway logins. Good thing too, because a long year ago &lt;a href="https://blog.lastpass.com/posts/notice-of-recent-security-incident" rel="noopener noreferrer"&gt;LastPass got breached&lt;/a&gt;. Nothing of mine was in it, but the message was loud enough. If the biggest name in cloud SaaS could leak vaults, the whole category had a trust problem I was not willing to live with. Your vault is sitting on somebody else's server. The day it gets breached you find out the same time everyone on Twitter does.&lt;/p&gt;

&lt;p&gt;So I went self-hosted, and the whole picture changed. No third-party operator to be breached, no support team to be socially engineered, no vendor that gets acquired and changes its terms next quarter. The blast radius shrinks to "my one server, in my own house". Self-hosted is the safer category. Full stop.&lt;/p&gt;

&lt;p&gt;But self-hosted is a spectrum. Closed-source self-hosted vaults are a non-starter for me, because a password is the most sensitive thing on my machine, and if I cannot read the binary that touches it, I cannot tell what it does with the master password in memory, whether it phones home for "telemetry", or what the next update is going to change without telling me. For most software, closed-source self-hosted is fine. For something that holds your passwords, credentials, API keys, SSH keys, recovery phrases, and whatever other secrets you put in it, it is not. A vault these days is a lot more than just passwords.&lt;/p&gt;

&lt;p&gt;So I went open-source self-hosted, and that was much better. I could read the code, pin the version, trust the boundary. But every time I looked at the schema, one detail kept bothering me. The server is sitting on the keys that decrypt my stuff. Self-hosting protects me from somebody else's incident. It does not protect me from my own.&lt;/p&gt;

&lt;p&gt;That was the missing piece. A vault where even my own server cannot read the data. If I was going to be the one trusting it, I might as well be the one making the calls.&lt;/p&gt;

&lt;p&gt;That is the itch that became vaultctl. Still self-hosted. Still open-source. Just with one extra property bolted in at the bottom: the server itself, even mine, cannot read your data. Ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build it when Vaultwarden exists
&lt;/h2&gt;

&lt;p&gt;Fair question. Vaultwarden is excellent and gets most of the way there. But it is a re-implementation of someone else's server protocol, which means the day I want to change how sharing works, or what the rekey path looks like, or whether a particular field is even allowed to hit the server, I am a guest in someone else's house. I either patch upstream or maintain a fork. vaultctl is what I built when I wanted to be the host of my own design instead.&lt;/p&gt;

&lt;p&gt;What vaultctl gives me that I could not get off the shelf:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;decrypt(...)&lt;/code&gt; function on the server. Not by policy, by absence.&lt;/strong&gt; Grep the source. The keys to decrypt user data do not live on the server side of the wire. Pop the server, leak ciphertext.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Member-removal does a full vault rekey.&lt;/strong&gt; When you remove someone from a shared vault, every item gets re-encrypted under a fresh key. Anything they kept a copy of is no longer good for new data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ed25519 signature pinning on every public key.&lt;/strong&gt; Even if my server is compromised and tries to hand a client an attacker-controlled public key during a re-wrap, the client refuses because the signature does not check out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One Go binary, three clients.&lt;/strong&gt; Web SPA, CLI, MV3 browser extension. All non-privileged, all hitting the same JSON API, all sharing the same crypto module. No client is treated special, no "admin" endpoint exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-file install.&lt;/strong&gt; The React SPA and the SQL migrations are embedded into the Go binary. &lt;code&gt;docker compose up -d&lt;/code&gt;, &lt;code&gt;migrate up&lt;/code&gt;, you are running on a ~45 MB image. No init containers, no nginx in front, no "remember to copy the static folder".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verifiable releases.&lt;/strong&gt; Cosign signatures, CycloneDX SBOMs, SLSA-L3 provenance attestations on public releases. For something you put your credentials into, this is the bare minimum.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shorter version: I had opinions about how a vault should behave that I could not get on someone else's roadmap. Building it myself was the cheaper move.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design bet: a constraint, not a feature
&lt;/h2&gt;

&lt;p&gt;Zero-knowledge is not a feature you add. It is a constraint you accept, and then it quietly forbids a lot of code you would otherwise write.&lt;/p&gt;

&lt;p&gt;The first time I felt this was when I sat down to design the "forgot password" flow. Of course there should be one. Every app has one. About thirty seconds in, the whole thing collapsed in my head. If the server can reset your password without your master password, then the server can derive the keys that decrypt your data. Which means the server &lt;em&gt;can&lt;/em&gt; decrypt your data. The whole premise becomes a lie.&lt;/p&gt;

&lt;p&gt;So there is no forgot password flow. There is a Recovery Kit, shown once at registration. If you lose both that and your master password, the data is gone. Not "gone until support". Gone.&lt;/p&gt;

&lt;p&gt;Once I accepted that, the rest of the questions answered themselves. No server-side search on titles, the server does not know the titles. No "admin can see what is in here" rescue path, if the admin can rescue, the admin can read. No "store this as plaintext just for now" anywhere in the codebase.&lt;/p&gt;

&lt;p&gt;The other big bet was Go. Honest reason: I write a lot more Go than Rust, and side projects that need me to re-learn the language between sessions do not get shipped. The technical reasons (single static binary, stdlib crypto, easy to read) helped, but they were not the deciding ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;p&gt;Self-host with &lt;code&gt;docker compose up -d&lt;/code&gt;. One Caddy in front of one vaultctl container in front of one Postgres. The vaultctl image is ~45 MB on a distroless base. The React SPA is built once and embedded into the Go binary with &lt;code&gt;//go:embed&lt;/code&gt;. SQL migrations are embedded too. The whole product ships as one file.&lt;/p&gt;

&lt;p&gt;Register the first user, save your Recovery Kit, you are in. The browser derives your master key with Argon2id, then derives an auth hash to prove who you are to the server and a separate key to encrypt your data. The master password never leaves the browser. The server stores the auth hash, the salt, and a pile of ciphertext blobs.&lt;/p&gt;

&lt;p&gt;Add a login and the client encrypts the title, URL, username, and password with the vault's symmetric key using AES-256-GCM. The server stores the ciphertext. Done.&lt;/p&gt;

&lt;p&gt;Same flow from the CLI:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULTCTL_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://vault.example.com
vaultctl login
vaultctl add login &lt;span class="nt"&gt;--name&lt;/span&gt; Reddit
vaultctl get Reddit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same from the browser extension. Same from the SPA. None of the three clients is privileged. They share the crypto primitives, hit the same JSON API, and the server treats them identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard parts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Actually enforcing "the server cannot decrypt"
&lt;/h3&gt;

&lt;p&gt;Anyone can write a README saying "encryption happens client-side". The interesting question is whether you can prove it. The way I enforce it is structural. There is no &lt;code&gt;decrypt(ciphertext, key)&lt;/code&gt; function anywhere in the Go codebase. None. Grep for &lt;code&gt;aes.NewCipher&lt;/code&gt; or &lt;code&gt;Open(&lt;/code&gt; and you will not find one that touches user data. The keys to decrypt do not exist on the server side of the wire.&lt;/p&gt;

&lt;p&gt;The thing that bit me was the audit log. First version happily wrote "user X added item Y to vault Z, named GitHub root token" in plain English, sitting in a Postgres column. I opened the table in psql to admire my work and just sat there going, oh no. I had built a system whose whole pitch was "the server cannot read your data", and built right next to it a log that recorded &lt;em&gt;exactly&lt;/em&gt; what was in your data in cleartext.&lt;/p&gt;

&lt;p&gt;Rewrote it the same evening. Audit messages are templated client-side, the rendered string is encrypted before it goes in.&lt;/p&gt;

&lt;p&gt;Zero-knowledge is a property of the entire pipeline, not a feature of the encrypt button.&lt;/p&gt;

&lt;h3&gt;
  
  
  The member-removal rekey, and the Ed25519 pin
&lt;/h3&gt;

&lt;p&gt;A team vault has a symmetric key. Every member has that key wrapped with their RSA public key. When I share with you, I unwrap my copy, re-wrap with your public key, and the server stores the new wrap.&lt;/p&gt;

&lt;p&gt;Now I remove you. Naive answer: delete your wrapped copy. Done.&lt;/p&gt;

&lt;p&gt;No. You already had the key. You used the vault. Nothing stops you from caching the unwrapped symmetric key on your machine. Deleting your wrap does not unlearn bytes. If you kept a copy, you can decrypt every item in that vault forever.&lt;/p&gt;

&lt;p&gt;The only real answer is a rekey. Every remaining member's client re-encrypts every item with a fresh symmetric key, then re-wraps that new key for each remaining member. A coordination nightmare in a transactional unit. I burned a lot of time getting batching and idempotency right.&lt;/p&gt;

&lt;p&gt;But the real surprise was quieter. When you re-wrap for another member, you fetch their public key from the server. Which means the server gets to tell you which public key belongs to that user. If the server is malicious or compromised, it can hand you a key it controls instead of the real one. You happily wrap the new vault key for "Alice", and what you actually did is hand the server the keys to the vault.&lt;/p&gt;

&lt;p&gt;The fix is signature pinning. Every user has a second keypair, an Ed25519 identity key, generated at registration. They sign their RSA public key with it. When another client fetches that public key, it also fetches the signature and verifies it against the identity public key on file. No valid signature, no wrap.&lt;/p&gt;

&lt;p&gt;The trust shifts from "the server told me this is Alice's public key" to "Alice told me this is Alice's public key, at registration time, mathematically attached to the bytes." The day I got that flow right end-to-end was the day vaultctl stopped being a single-user toy.&lt;/p&gt;

&lt;h3&gt;
  
  
  One Go binary, three clients
&lt;/h3&gt;

&lt;p&gt;I wanted three clients sharing one server and one set of crypto primitives, with one binary to deploy.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cmd/server/&lt;/code&gt; is the entry point and does everything. &lt;code&gt;vaultctl server&lt;/code&gt; runs the API. &lt;code&gt;vaultctl migrate up&lt;/code&gt; applies the embedded SQL migrations. &lt;code&gt;vaultctl login&lt;/code&gt; / &lt;code&gt;add&lt;/code&gt; / &lt;code&gt;get&lt;/code&gt; / &lt;code&gt;ls&lt;/code&gt; are the CLI. One binary, multiple subcommands, dispatched through Cobra. The CLI is just another HTTP client hitting the same JSON API the SPA uses. No privileged "internal" endpoints.&lt;/p&gt;

&lt;p&gt;The SPA is where &lt;code&gt;embed&lt;/code&gt; earned its keep. &lt;code&gt;make web-build&lt;/code&gt; runs &lt;code&gt;vite build&lt;/code&gt; into &lt;code&gt;web/dist/&lt;/code&gt;. A small Go file does &lt;code&gt;//go:embed dist/*&lt;/code&gt; and folds it into the binary at compile time. The HTTP handler serves the SPA from the embedded filesystem. No second container, no nginx fronting a static folder.&lt;/p&gt;

&lt;p&gt;The browser extension is the only piece outside the Go binary, because it has to ship through the Chrome and Firefox stores. But it uses the same JSON API and the same crypto primitives the SPA does, through a shared TypeScript module both &lt;code&gt;web/&lt;/code&gt; and &lt;code&gt;extension/&lt;/code&gt; import.&lt;/p&gt;

&lt;p&gt;Fresh install: &lt;code&gt;docker compose up -d&lt;/code&gt;, &lt;code&gt;migrate up&lt;/code&gt;, done. No build steps on the target. No init scripts. For something whose whole job is to be the most boring piece of infrastructure in your life, that simplicity is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;If any of this is useful, the whole thing is open source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/vineethkrishnan/vaultctl" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/vaultctl&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://vaultctl.vinelabs.de" rel="noopener noreferrer"&gt;vaultctl.vinelabs.de&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; AGPL-3.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would genuinely like to be wrong about something here before someone trusts it with their AWS root key. If you read the code and find a hole, please open an issue.&lt;/p&gt;

&lt;p&gt;So that is where I will stop. If you have a different way of doing this, I genuinely want to hear it. Drop me a note.&lt;/p&gt;

</description>
      <category>go</category>
      <category>cryptography</category>
      <category>zeroknowledge</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>the slow request my APM never told me about</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sat, 23 May 2026 13:07:56 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/the-slow-request-my-apm-never-told-me-about-a77</link>
      <guid>https://dev.to/vineethnkrishnan/the-slow-request-my-apm-never-told-me-about-a77</guid>
      <description>&lt;h1&gt;
  
  
  the slow request my APM never told me about
&lt;/h1&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%2Fvineethnk.in%2Fblog%2Fapm-blind-spot-hero.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%2Fvineethnk.in%2Fblog%2Fapm-blind-spot-hero.png" alt="A developer holding a flashlight pointed at one glowing server box in a row of dark ones, revealing a busy worker process inside, flat editorial illustration, soft blue and warm amber palette, clean minimal design." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: one specific user was loading a page in six minutes while everyone else loaded it in under a second. The APM dashboard had nothing on it. Turns out APMs drop requests that run too long, so the worst requests on your system are exactly the ones the dashboard cannot see. I learned to attach a sampling profiler to the live worker and read its stack directly. Different tool, different rules. Saved my week.&lt;/p&gt;

&lt;h2&gt;
  
  
  the part where the dashboard goes quiet
&lt;/h2&gt;

&lt;p&gt;So there I was, staring at the APM dashboard, watching it lie to me by omission.&lt;/p&gt;

&lt;p&gt;One user was reporting a six-minute page load. I could reproduce it. I could see it in the browser network panel. I could SSH into the box and watch one worker peg a CPU for the full duration. The thing was happening, on the box I was logged into, while I was watching.&lt;/p&gt;

&lt;p&gt;And the APM had nothing. No trace. No slow query. No outlier transaction. Just the same boring p95 numbers I had been looking at for weeks.&lt;/p&gt;

&lt;p&gt;For the longest time I assumed I was holding the APM wrong. Surely there is a filter, a date range, a sample rate dial I am missing. Surely the slowest request on the entire system is not the one piece of data we cannot pull up.&lt;/p&gt;

&lt;p&gt;Turns out, kind of, yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  why APMs cannot see your worst request
&lt;/h2&gt;

&lt;p&gt;Most APMs work by hooking every function entry and exit in your runtime. The agent collects the data, buffers it, then uploads a full trace to the dashboard at the end of the request. This is wonderful when it works, because you get exact per-function timings. But it has two limits that matter the moment things get really bad.&lt;/p&gt;

&lt;p&gt;One, the agent has to sub-sample. If you traced every request, the overhead would eat your servers. So most APMs trace some percentage of requests and aggregate the rest into thin metrics. On the box I was debugging, the sample rate was 25 percent. One in four. Even with no other problem, a single user would need to click four times on average before one of their requests got fully captured. They had clicked many more times than that. Still nothing.&lt;/p&gt;

&lt;p&gt;Two, there is a hard cap on trace duration. Past some threshold (about five minutes on the APM I was using, possibly configurable, possibly not, I never got a confident answer), the trace is dropped during upload. The reason is sensible. A six-hour PHP request would generate a trace the size of a small novel and the upload pipeline would buckle. So the APM protects itself.&lt;/p&gt;

&lt;p&gt;But here is the cruel irony you only notice once you are in this situation. The slower a single request is, the less likely your APM is to tell you why. Slow requests are exactly the ones that get dropped. Slow requests are exactly the ones you most need a trace for. The dashboard cannot help you with the problem that needs help the most.&lt;/p&gt;

&lt;p&gt;Happened to you too, right? You go looking for the worst request in the system and the tool that was supposed to find it for you just shrugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  what a sampling profiler does differently
&lt;/h2&gt;

&lt;p&gt;This is the bit where someone smarter than me says "just use a sampling profiler". I had heard about them before. I had never actually reached for one. So I went and read what they do.&lt;/p&gt;

&lt;p&gt;A sampling profiler is not the same shape as an APM. It does not instrument anything. It does not hook function entries. It does not even know about HTTP requests. What it does is sit outside your runtime, attach via the same syscall a debugger uses (&lt;code&gt;ptrace&lt;/code&gt; on Linux), and read the worker's memory directly. Specifically, it walks the language VM's call stack from &lt;code&gt;/proc/PID/mem&lt;/code&gt; at whatever rate you ask for. Ninety nine snapshots a second is a comfortable default.&lt;/p&gt;

&lt;p&gt;That is it. That is the whole tool. A loop that goes "read stack, write to file, sleep, repeat".&lt;/p&gt;

&lt;p&gt;The implications are what surprised me. Because it never hooks anything, there is zero overhead on requests you are not sampling. Because it does not buffer a trace, it does not care how long the request takes. A six-hour request, a six-minute request, a six-millisecond request, it all looks the same to the sampler. Read stack. Write to file. Sleep.&lt;/p&gt;

&lt;p&gt;For PHP this tool is called phpspy. I built it from source on the box, picked the worker that was clearly burning CPU on the bad request, attached the sampler for a minute, and pulled the output back.&lt;/p&gt;

&lt;h2&gt;
  
  
  the wrong turn I have to tell you about
&lt;/h2&gt;

&lt;p&gt;This is the part where I make myself look bad, but the lesson is worth the embarrassment.&lt;/p&gt;

&lt;p&gt;My first aggregation came back saying "ninety seven percent of samples contain function X in the stack". X was a middleware that runs on every request. I read this as "X is the bottleneck", wrote a fix for X, deployed it, measured, and the request was still six minutes.&lt;/p&gt;

&lt;p&gt;I felt stupid for a beat. Then I felt stupider when I went back and actually read the data.&lt;/p&gt;

&lt;p&gt;Of course ninety seven percent of samples contained X in their stack. Middleware runs on every single request. It appears in one hundred percent of samples by definition. The "ninety seven" was only "not one hundred" because the worker was sometimes between requests. I had aggregated by "does this function appear anywhere in the stack" and treated that as a signal. It was not a signal. It was a property of how requests are built.&lt;/p&gt;

&lt;p&gt;The actual signal in a sampling profiler is the leaf of each stack. That is, the function the worker is currently executing at the moment you sampled it. Not "anywhere in the stack". The very bottom. The leaf aggregation had already been telling me the truth in the same output. I just had not read it. Function X was at the top of the call chain; the actual hot code was six frames deeper, sitting at the leaf, plain as day, in the same dump I had been staring at.&lt;/p&gt;

&lt;p&gt;Tell me I am not the only one who has done this. You get a dataset, you skim it, you grab the biggest number, you start typing. The biggest number was not the right number. Slow down and read the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  the right shape, after the dust settled
&lt;/h2&gt;

&lt;p&gt;Re-ran the sampler. Aggregated by leaf this time. Sliced one more time at a mid-stack depth to see what was calling that leaf, because sometimes the same leaf gets reached from very different parents and you need to know the parent to understand the fix.&lt;/p&gt;

&lt;p&gt;Within minutes the picture was unambiguous. Specific function. Specific loop. Specific reason it was running ten thousand times for that one user and twice for everyone else. From there it was just engineering.&lt;/p&gt;

&lt;p&gt;The whole thing, beginning to end, took less than half a day once I had the right data. The two days before that, where I was poking at the APM and writing fixes for the wrong layer, those were the expensive days. Not the debugging itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  the bigger thing I learned about my tools
&lt;/h2&gt;

&lt;p&gt;After it was done I sat with the question for a bit, because I do not love being surprised by my own tooling.&lt;/p&gt;

&lt;p&gt;The reframe was that APMs and sampling profilers are not the same tool with different names on them. They are different shapes for different problems.&lt;/p&gt;

&lt;p&gt;The APM is for "across thousands of requests over weeks, where am I generally slow". It is for the aggregated view. It has to sub-sample. It has to cap trace size. It is wonderful at telling you that your checkout endpoint has a creeping p95. It is bad at telling you why one specific request you can reproduce right now is six minutes long. Those are different jobs.&lt;/p&gt;

&lt;p&gt;The sampling profiler is for "this one process is doing something I do not understand, right now, while I am watching". It does not know about requests. It does not aggregate over weeks. But it does not care about duration, sample rate, trace size, or any of the other constraints the APM has to respect. It will happily sit next to a runaway worker for an hour and tell you exactly which line of code is on fire.&lt;/p&gt;

&lt;p&gt;You need both. You reach for them at different moments. The hard part is noticing which moment you are in, because by default we all reach for the dashboard, because the dashboard is what we are used to.&lt;/p&gt;

&lt;h2&gt;
  
  
  so what does this look like outside PHP
&lt;/h2&gt;

&lt;p&gt;The shape is general. Most runtimes have a sampling profiler that nobody tells you about until you are in trouble.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node has &lt;code&gt;node --inspect&lt;/code&gt; plus Chrome DevTools for live attaching, and the broader Linux ecosystem has eBPF-based profilers like Pyroscope and Parca that work on any process.&lt;/li&gt;
&lt;li&gt;Python has &lt;code&gt;py-spy&lt;/code&gt;, which is basically the same idea as phpspy, written for the Python VM. Same trick. Same syscall. Different language.&lt;/li&gt;
&lt;li&gt;The JVM has async-profiler and a dozen other things, mostly because the JVM crowd has been doing this for longer than the rest of us.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is not the specific tool. The point is to know it exists in your stack before you need it. Knowing about py-spy when your Python app is stuck is the difference between a four-hour incident and a four-day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  what I now do differently
&lt;/h2&gt;

&lt;p&gt;Two things changed in how I work after this.&lt;/p&gt;

&lt;p&gt;First, when the APM says nothing, I do not assume the problem is small. I assume the APM cannot see it. There is a difference. The first reaction makes you close the ticket. The second one makes you reach for the right tool.&lt;/p&gt;

&lt;p&gt;Second, before I write any fix off the back of profiler data, I check what I am aggregating by. Leaf function. Caller of leaf. Time spent inside, not "appears anywhere". The shape of the question decides the shape of the answer, and if you ask the wrong shape you will get a confident answer that is also wrong.&lt;/p&gt;

&lt;p&gt;Not going to pretend this was a perfect writeup. But if even one part of it helped someone avoid the headache I went through, then it was worth putting down. See you in the next one.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>profiling</category>
      <category>apm</category>
      <category>production</category>
    </item>
    <item>
      <title>Pointing vinelabs.de at my Mac mini through a Cloudflare Tunnel</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Wed, 20 May 2026 14:42:45 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/pointing-vinelabsde-at-my-mac-mini-through-a-cloudflare-tunnel-5h85</link>
      <guid>https://dev.to/vineethnkrishnan/pointing-vinelabsde-at-my-mac-mini-through-a-cloudflare-tunnel-5h85</guid>
      <description>&lt;p&gt;{/*&lt;br&gt;
HERO IMAGE PROMPT - paste this into your usual image tool, then delete this comment block:&lt;/p&gt;

&lt;p&gt;Prompt:&lt;br&gt;
A small 2018 Mac mini sitting on a wooden desk, a translucent orange tunnel arching out of it through a soft pastel cloud, with a tiny green vine leaf hovering near the cloud, connecting to a small browser window with a green address bar on the other side, editorial illustration style, warm pastel colors, clean linework, gentle homelab vibe, no text labels.&lt;/p&gt;

&lt;p&gt;Dimensions: 1200 x 630 (1.91:1, OpenGraph / social card ratio, matches the other hero images)&lt;br&gt;
Save as: public/blog/cloudflare-tunnel-hero.png&lt;br&gt;
*/}&lt;/p&gt;
&lt;h1&gt;
  
  
  Pointing vinelabs.de at my Mac mini through a Cloudflare Tunnel
&lt;/h1&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.amazonaws.com%2Fuploads%2Farticles%2Fo6vlsrjoq66pgjq3s3xf.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.amazonaws.com%2Fuploads%2Farticles%2Fo6vlsrjoq66pgjq3s3xf.png" alt="A 2018 Mac mini on a wooden desk with a soft orange tunnel arching out of it through a pastel cloud, a small green vine leaf hovering near the cloud, connecting to a tiny browser window on the other side, editorial illustration, warm pastel colors." width="799" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I have a domain, &lt;a href="https://vineethnk.in/blog/how-i-ended-up-buying-vinelabs-de" rel="noopener noreferrer"&gt;vinelabs.de&lt;/a&gt;, and a Mac mini at home running my &lt;a href="https://vineethnk.in/blog/mac-mini-homelab-setup" rel="noopener noreferrer"&gt;homelab&lt;/a&gt;. Until recently those two were strangers. A Cloudflare Tunnel turned out to be the simplest way to connect them - no port forwarding, no certbot, no public IP gymnastics. Two services ended up behind the tunnel, three stayed inside the Tailscale tailnet because they have no business being public. This post is the why and the how.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Two things that needed to meet
&lt;/h2&gt;

&lt;p&gt;In one corner, &lt;a href="https://vineethnk.in/blog/how-i-ended-up-buying-vinelabs-de" rel="noopener noreferrer"&gt;vinelabs.de&lt;/a&gt;. A domain I bought one weekend after realising my npm and Composer publishes deserved a proper identity, not my personal GitHub handle. It has been sitting there with a small landing page and not much else.&lt;/p&gt;

&lt;p&gt;In the other corner, my &lt;a href="https://vineethnk.in/blog/mac-mini-homelab-setup" rel="noopener noreferrer"&gt;Mac mini homelab&lt;/a&gt;. Vaultwarden, Uptime Kuma, ntfy, n8n, an agent webhook that runs Claude Code, all behind Tailscale and Caddy, with restic backups going to Backblaze B2. Reachable from anywhere I am logged into my tailnet, which is fine for me, but not great when I want to share a status page with a friend who is not on Tailscale.&lt;/p&gt;

&lt;p&gt;The obvious bridge was a Cloudflare Tunnel. I had been chewing on it ever since I wrote the homelab post. So I sat down one evening and finally did it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why a tunnel and not a forwarded port
&lt;/h2&gt;

&lt;p&gt;Quick context for anyone new to this. The "normal" way to expose a service from your home network to the internet is to log into your router, forward a port from your public IP to the box running the service, and pray that your ISP is not putting you behind CGNAT. Some do. Mine kind of does.&lt;/p&gt;

&lt;p&gt;A Cloudflare Tunnel flips that around. The &lt;code&gt;cloudflared&lt;/code&gt; daemon on the Mac mini opens an outbound connection to Cloudflare's edge and holds it. When a request hits &lt;code&gt;status.vinelabs.de&lt;/code&gt;, Cloudflare sends it back down that already-open connection. The Mac mini then talks to the local service on &lt;code&gt;127.0.0.1:3001&lt;/code&gt; and ships the response back the same way. Nothing is listening on a public port at home. The router has no idea any of this is happening.&lt;/p&gt;

&lt;p&gt;The wins are real.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No port forward, no router config, no UPnP.&lt;/li&gt;
&lt;li&gt;It works through CGNAT, because the connection is outbound.&lt;/li&gt;
&lt;li&gt;TLS is terminated by Cloudflare with a real cert, automatically.&lt;/li&gt;
&lt;li&gt;I can add Cloudflare Access on top if I want to gate a service with email-based auth, with zero code changes.&lt;/li&gt;
&lt;li&gt;Free for personal use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is that all public-side traffic now goes through Cloudflare. I am okay with that for a few hobby services. For something that actually mattered, I would think harder.&lt;/p&gt;
&lt;h2&gt;
  
  
  What gets a public URL, and what does not
&lt;/h2&gt;

&lt;p&gt;This was the decision I sat with for a bit before writing a single line of config. Not everything in my homelab deserves a public hostname. The rule I settled on is simple. If a service has strong authentication and a sensible signup story, it can sit on a tunnel. If its only protection is "nobody knows the URL" or "the topic name is the password", it stays inside the Tailscale tailnet, full stop.&lt;/p&gt;

&lt;p&gt;That gave me two short lists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Behind the tunnel, on vinelabs.de:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;home.vinelabs.de&lt;/code&gt;, a small landing page on the apex. Public, read-only, harmless.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status.vinelabs.de&lt;/code&gt;, the Uptime Kuma instance. The visitor-facing dashboard is read-only and useful to share, and Kuma has real authentication on the admin side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tailnet only:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vaultwarden.&lt;/strong&gt; This one is obvious, it is my password vault. Even with Vaultwarden's solid auth, the math is "what is the upside of a public URL for a password manager". Roughly zero. Tailnet only, forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n.&lt;/strong&gt; This one took a beat to articulate properly, so let me. n8n is a workflow automation tool, which is innocent enough on the surface. The catch is that n8n can execute arbitrary code, hit any third-party API, and stores OAuth tokens and API keys for every service my flows talk to, Gmail credentials, Slack tokens, GitHub PATs. Even if I added basic auth in front, the blast radius if anything ever went wrong on the auth layer is too high to think about. An automation engine plus a wallet of credentials sitting behind one login screen is not a thing I want a public URL for. Tailnet is its perimeter, period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ntfy.&lt;/strong&gt; ntfy is a push-notification service. Its security model is, the topic name is the secret. If you know the topic name, you can publish to it (which means push to my phone), and you can also subscribe to it (which means read every notification I receive on that topic). The whole utility of ntfy depends on the topic name staying private. Putting the server on a public URL means anyone scanning the internet could guess topic names brute-force-style, and topic names are not bcrypt-hashed passwords, they are just strings. Inside the tailnet, the only devices that can even reach the ntfy server are mine. The model holds. Outside, it leaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you self-host even a couple of things, you probably know this feeling. The moment you ask "okay, do I actually want this thing reachable from a coffee shop in Frankfurt", and your honest answer is no. Three services on this box answered no. So the tunnel had two real hostnames plus a catch-all, and that was it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting up the tunnel itself
&lt;/h2&gt;

&lt;p&gt;The setup was honestly easier than I expected. A handful of commands and one YAML file, and that was it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;cloudflared

&lt;span class="c"&gt;# Sign in to Cloudflare (opens a browser to authorize the account)&lt;/span&gt;
cloudflared tunnel login

&lt;span class="c"&gt;# Create the tunnel itself, which also drops a credentials JSON in ~/.cloudflared/&lt;/span&gt;
cloudflared tunnel create vinelabs-mini

&lt;span class="c"&gt;# Wire up DNS for each public hostname (creates a proxied CNAME)&lt;/span&gt;
cloudflared tunnel route dns vinelabs-mini home.vinelabs.de
cloudflared tunnel route dns vinelabs-mini status.vinelabs.de

&lt;span class="c"&gt;# Run it&lt;/span&gt;
cloudflared &lt;span class="nt"&gt;--config&lt;/span&gt; ~/.cloudflared/config.yml tunnel run vinelabs-mini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;tunnel route dns&lt;/code&gt; command is worth flagging. It calls Cloudflare's API directly, creates the proxied CNAME pointing at your tunnel, and is idempotent. If the CNAME already exists pointing at the same tunnel, it leaves it alone. If it exists pointing somewhere else, it tells you, instead of silently overwriting. So you never need to touch the DNS dashboard for the happy path. Worth committing to muscle memory early, it saves headaches down the line.&lt;/p&gt;

&lt;p&gt;For persistence I wrote a tiny launchd plist so cloudflared starts at boot and comes back up if it ever crashes. Nothing fancy in it, just &lt;code&gt;RunAtLoad&lt;/code&gt; and &lt;code&gt;KeepAlive&lt;/code&gt; and the daemon sorts itself out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config.yml that runs the tunnel
&lt;/h2&gt;

&lt;p&gt;The file that actually defines what the tunnel does lives at &lt;code&gt;~/.cloudflared/config.yml&lt;/code&gt;. Here is what mine ended up looking like.&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;tunnel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx&lt;/span&gt;
&lt;span class="na"&gt;credentials-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/Users/vineeth/.cloudflared/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.json&lt;/span&gt;

&lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;home.vinelabs.de&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:80&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;status.vinelabs.de&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:3001&lt;/span&gt;

  &lt;span class="c1"&gt;# Catch-all (required)&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http_status:404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth calling out for anyone new to cloudflared config files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tunnel ID and credentials file at the top&lt;/strong&gt; are how cloudflared knows which tunnel it is and which credentials to use when it phones home to Cloudflare. You get both from &lt;code&gt;cloudflared tunnel create&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each ingress rule has a hostname and a service.&lt;/strong&gt; The hostname is the public name a request comes in on. The service is where cloudflared should forward that request locally. &lt;code&gt;http://localhost:3001&lt;/code&gt; is Uptime Kuma running on the Mac mini. Plain HTTP is fine here, because the connection is from cloudflared to a service on the same box, never over the wire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The last rule has no hostname.&lt;/strong&gt; That is the catch-all, and it is required. cloudflared will refuse to start without one. If a request comes in for a hostname that does not match any earlier rule, this is where it lands. I use &lt;code&gt;http_status:404&lt;/code&gt;, because that is the truthful answer for an unknown hostname. The point is, you need a fallback. Without it, the daemon does not run.&lt;/p&gt;

&lt;p&gt;That is the whole tunnel config. Two public hostnames, one catch-all, twelve lines of YAML. Less than I expected when I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it actually work
&lt;/h2&gt;

&lt;p&gt;Once cloudflared was up and the DNS records were in place, I hit &lt;code&gt;https://status.vinelabs.de&lt;/code&gt; from my laptop, off Tailscale, on a regular consumer connection. Got the redirect to Uptime Kuma's dashboard, served via Cloudflare. Exactly the response I was hoping for.&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.amazonaws.com%2Fuploads%2Farticles%2Fp19t69c70up3figfwaso.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.amazonaws.com%2Fuploads%2Farticles%2Fp19t69c70up3figfwaso.png" alt="Terminal output showing curl -sSI against status.vinelabs.de returning HTTP/2 302 with location /dashboard, served via Cloudflare." width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Real cert, real public URL, real public internet, and not a single port forwarded on my router. The Mac mini just kept doing its thing. Cloudflare did the rest.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;home.vinelabs.de&lt;/code&gt; came up the same way. Two for two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to from here
&lt;/h2&gt;

&lt;p&gt;The bones are in place. What I want to add on top, roughly in this order.&lt;/p&gt;

&lt;p&gt;First, a small phone shortcut that POSTs to the agent webhook through its own tunnel, fronted by Cloudflare Access so only my own Google account can hit it. Then the n8n flows will start consuming &lt;code&gt;status.vinelabs.de&lt;/code&gt; as their uptime source, instead of polling each container directly. And somewhere down the line, a real landing page on &lt;code&gt;home.vinelabs.de&lt;/code&gt; that lists the projects living under the &lt;a href="https://vineethnk.in/blog/how-i-ended-up-buying-vinelabs-de" rel="noopener noreferrer"&gt;vinelabs-de&lt;/a&gt; org, instead of the placeholder it has right now.&lt;/p&gt;

&lt;p&gt;The domain finally has a job. The Mac mini finally has a face. The two of them are talking through a quiet outbound connection that my router never even noticed.&lt;/p&gt;

&lt;p&gt;Okay, that is enough from me for today. If any of this saved you the evening of router-port-forwarding pain I used to do, that is the whole point of writing it down. Until the next one, take it easy.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>cloudflaretunnel</category>
      <category>macmini</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>My Raspberry Pi could not carry the dream. A 2018 Mac mini could.</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Mon, 18 May 2026 13:50:57 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/my-raspberry-pi-could-not-carry-the-dream-a-2018-mac-mini-could-1p67</link>
      <guid>https://dev.to/vineethnkrishnan/my-raspberry-pi-could-not-carry-the-dream-a-2018-mac-mini-could-1p67</guid>
      <description>&lt;h1&gt;
  
  
  My Raspberry Pi could not carry the dream. A 2018 Mac mini could.
&lt;/h1&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.amazonaws.com%2Fuploads%2Farticles%2Fl92i7sq601e8t41t9b0m.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.amazonaws.com%2Fuploads%2Farticles%2Fl92i7sq601e8t41t9b0m.png" alt="A 2018 Mac mini sitting on a wooden desk with small glowing icons hovering above it representing a vault, a notification bell, a workflow, and a small robot, soft editorial illustration, warm pastel colors." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I had a Raspberry Pi running a couple of small things at home. It was a good guy for small things. But I wanted a homelab that hosts my self hosted apps and also runs a small army of agents on my command, 24x7. The Pi would just die. A colleague had a 2018 Mac mini sitting unused, I told him what I had in mind, he handed it over without thinking twice. I wiped it clean, took it from Big Sur to macOS 15 Sequoia, installed Ghostty and my usual terminal things, then sat down after work and did not stop until past midnight. Vaultwarden, Uptime Kuma, ntfy, n8n, an agent webhook that runs Claude Code, all behind Tailscale and Caddy, with restic backups going to Backblaze B2. This post is the story of that one long evening, including the four or five places I got stuck.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Pi was never going to do it
&lt;/h2&gt;

&lt;p&gt;I have been running a &lt;strong&gt;Raspberry Pi 4&lt;/strong&gt; at home for a while. It was doing simple things, Pi-hole, a couple of cron jobs, a tiny dashboard. For that kind of work the Pi is perfect, no complaints.&lt;/p&gt;

&lt;p&gt;But I had a different dream sitting in my head for some time. I wanted to run my own little army of agents. Not just one assistant on my laptop, but a setup at home that listens to me from anywhere, runs Claude Code on a prompt, sends the result to my phone, and goes back to waiting. On top of that I wanted to host my own password manager, my own push notifications, my own uptime checks, my own workflow tool. Basically the things I keep handing over to free tiers of various SaaS, brought home.&lt;/p&gt;

&lt;p&gt;Dreams have wings like a phoenix and they want to fly sky high. But reality has weight. The Pi was not going to lift it. 4 gigs of RAM, one SoC, no real headroom. The moment I add Docker and a couple of containers and an agent that spawns subprocesses, the poor guy is on the floor.&lt;/p&gt;

&lt;p&gt;So the dream was on hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Mac mini arrived
&lt;/h2&gt;

&lt;p&gt;A colleague of mine had a 2018 Mac mini sitting at his place, unused. I knew about it. One day we were talking and I told him exactly what I wanted to build with it. The agent army, the homelab apps, the whole thing. He did not even think twice. He said take it. A great guy, no hesitation, no hold back. I owe him a proper dinner.&lt;/p&gt;

&lt;p&gt;The mini arrived. Spec is 2018 spec, so by 2026 standards it is a baseline machine. 8GB RAM, 128GB SSD, Intel inside. For my homelab that is more than fine. The Pi would have died running half of it, this one will not even sweat.&lt;/p&gt;

&lt;p&gt;It came with macOS 11 Big Sur on it. I wanted at least macOS 13, ideally the latest. Either way I was going to wipe the whole disk and start fresh, so the old OS did not matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiping it clean
&lt;/h2&gt;

&lt;p&gt;First try was the usual one. Hold &lt;strong&gt;Cmd-R&lt;/strong&gt; during boot to go into Recovery. The Mac mini just kept booting into Big Sur as if I had not pressed anything. Tried it a couple of times. Same result.&lt;/p&gt;

&lt;p&gt;So I went to &lt;strong&gt;Internet Recovery&lt;/strong&gt; instead. That is &lt;strong&gt;Cmd-Option-R&lt;/strong&gt; (Cmd-Alt-R on the same key) held during boot. The screen showed a spinning globe instead of the Apple logo, the Mac fetched the recovery image straight from Apple's servers over Wi-Fi, and after a few minutes I was in the proper recovery utility. Slower, but it works when the local recovery partition decides it does not want to talk.&lt;/p&gt;

&lt;p&gt;From there it was Disk Utility, wipe the internal SSD completely, no traces of the previous owner. Then Reinstall macOS from the same recovery menu, let it pull Big Sur back over the internet.&lt;/p&gt;

&lt;p&gt;Once Big Sur was back on a clean disk, I went to Software Update and walked it all the way up to &lt;strong&gt;macOS 15 Sequoia&lt;/strong&gt;. Big update, two reboots, no drama.&lt;/p&gt;

&lt;p&gt;Happy face. Clean disk, latest OS, ready to be a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the terminal niceties
&lt;/h2&gt;

&lt;p&gt;Before anything serious I do my usual &lt;a href="https://vineethnk.in/blog/my-zshrc-archaeology" rel="noopener noreferrer"&gt;terminal ritual&lt;/a&gt;. I install &lt;strong&gt;Ghostty&lt;/strong&gt; as my terminal, pull in my dotfiles, get the same prompt and aliases I am used to on every machine. If you want the long story behind that 350-line &lt;code&gt;.zshrc&lt;/code&gt; and the 68-line &lt;code&gt;.bash_aliases&lt;/code&gt; time capsule it grew out of, that whole archaeology is in its own blog. About 20 minutes of dotfile-shuffling later, the Mac mini felt like my own machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Ghostty here, when I run iTerm2 on the Air
&lt;/h3&gt;

&lt;p&gt;A small detour worth making, because people ask. On my MacBook Air, the one I use every day for actual work, my terminal is &lt;strong&gt;iTerm2&lt;/strong&gt;. Has been for years. The reason is simple, iTerm2 is rich. Profiles per project with their own colors and fonts, split panes that can each be a different profile, hotkey window I can summon with a keystroke, instant replay of past output, shell integration that knows where a command starts and ends, the toolbelt, the status bar, badges, triggers, captured output, password manager, semantic history. Eleven years of polish piled into one app. For a working laptop where I am juggling four tabs of SSH plus a Vim plus a long-running build, that pile of features earns its keep every day.&lt;/p&gt;

&lt;p&gt;On the Mac mini, the calculation flips entirely. This box is a server. I am not going to sit in front of it for eight hours debugging Rust. Most of the time it does its job with no terminal open at all. When I do open a terminal here, it is for a quick check, a &lt;code&gt;docker compose ps&lt;/code&gt;, a &lt;code&gt;tail -f&lt;/code&gt;, maybe a &lt;code&gt;restic snapshots&lt;/code&gt;. Short visits, not full work sessions.&lt;/p&gt;

&lt;p&gt;For that shape of usage, &lt;strong&gt;Ghostty&lt;/strong&gt; wins on three things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is fast and small.&lt;/strong&gt; Native, GPU-rendered, written in Zig, starts almost instantly. A homelab server has better things to do with its 8 GB of RAM than feed a feature-heavy terminal that I open twice a day. Ghostty stays out of the way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One config file, no clicking through preferences.&lt;/strong&gt; iTerm2's config lives in a binary plist that you can technically check into git but in practice you set up through twenty tabs of GUI preferences. Ghostty has a single text file at &lt;code&gt;~/.config/ghostty/config&lt;/code&gt;, plain key-value, version-controllable, easy to push around with my dotfiles. On a server I rebuild rarely but cleanly, that one file is the whole story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less surface area to break.&lt;/strong&gt; No plugin system, no AppleScript dictionary, no triggers, no coprocesses, no profile imports, no semantic history. Just a terminal. Fewer moving parts means fewer things to go wrong on a machine I want to leave running for months.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not that Ghostty is better than iTerm2. It is that the two terminals are tuned for different jobs. iTerm2 is a workshop, Ghostty is a clean utility room. The Mac mini gets the utility room. The Air stays the workshop. Both end up with the same prompt and the same aliases through dotfiles, so the muscle memory does not break when I switch.&lt;/p&gt;

&lt;p&gt;With the terminal sorted, I sat down with a plan already in my head. I knew exactly what I wanted on this box, in what order, and how each piece should talk to the next. Choosing Colima over Docker Desktop, Tailscale over a raw VPN, Caddy over Nginx, restic over rclone, the four apps in the stack, the launchd schedule for backups, the port layout, all of it was decided before I ran the first command. The shell was open and that is where the real work happened.&lt;/p&gt;

&lt;p&gt;What I thought would be a 2 hour job stretched into a full evening that ran past midnight, mostly because of the gotchas I am about to walk through. The actual command-running was fast. The "wait, why is this not working" parts were not, and those were the parts where I sat with the logs, read them carefully, and worked out what to change.&lt;/p&gt;

&lt;p&gt;Below is the same setup, phase by phase, with the four or five places I got stuck and what unstuck them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1, making it a 24x7 server
&lt;/h2&gt;

&lt;p&gt;A Mac is a laptop OS by default. It wants to sleep, dim the screen, idle the disks. For a homelab I want the opposite. &lt;code&gt;pmset&lt;/code&gt; is the way to tell it.&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="nb"&gt;sudo &lt;/span&gt;pmset &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;0
&lt;span class="nb"&gt;sudo &lt;/span&gt;pmset &lt;span class="nt"&gt;-a&lt;/span&gt; disksleep 0
&lt;span class="nb"&gt;sudo &lt;/span&gt;pmset &lt;span class="nt"&gt;-a&lt;/span&gt; autorestart 1
&lt;span class="nb"&gt;sudo &lt;/span&gt;pmset &lt;span class="nt"&gt;-a&lt;/span&gt; womp 1
&lt;span class="nb"&gt;sudo &lt;/span&gt;pmset &lt;span class="nt"&gt;-a&lt;/span&gt; tcpkeepalive 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Translation, in order.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sleep 0&lt;/code&gt;, the system never sleeps.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;disksleep 0&lt;/code&gt;, the disks never spin down.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;autorestart 1&lt;/code&gt;, automatic restart after a power cut.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;womp 1&lt;/code&gt;, wake on magic packet so I can wake it remotely.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tcpkeepalive 1&lt;/code&gt;, keep TCP alive across long-lived connections.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I left &lt;code&gt;displaysleep&lt;/code&gt; at the default 10 minutes, because I sometimes plug a monitor into this thing and I do want the screen to go off when I am not using it.&lt;/p&gt;

&lt;p&gt;To check what stuck, &lt;code&gt;pmset -g&lt;/code&gt; shows the current settings:&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.amazonaws.com%2Fuploads%2Farticles%2Fn7kdnwrmj8qvuc24fw8x.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.amazonaws.com%2Fuploads%2Farticles%2Fn7kdnwrmj8qvuc24fw8x.png" alt="pmset -g output showing sleep 0, disksleep 0, autorestart 1, tcpkeepalive 1, womp 1." width="515" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then the firewall. I turned on the application firewall but kept it permissive, signed software allowed, stealth mode off. The reason is my threat model is the internet, not my LAN. Tailscale is going to be the real perimeter. If I lock the firewall down hard now, I will spend the next hour fighting it for every brew service I install.&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="nb"&gt;sudo&lt;/span&gt; /usr/libexec/ApplicationFirewall/socketfilterfw &lt;span class="nt"&gt;--setglobalstate&lt;/span&gt; on
&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/libexec/ApplicationFirewall/socketfilterfw &lt;span class="nt"&gt;--setallowsigned&lt;/span&gt; on
&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/libexec/ApplicationFirewall/socketfilterfw &lt;span class="nt"&gt;--setallowsignedapp&lt;/span&gt; on
&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/libexec/ApplicationFirewall/socketfilterfw &lt;span class="nt"&gt;--setstealthmode&lt;/span&gt; off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the first stuck moment.&lt;/p&gt;

&lt;h3&gt;
  
  
  SSH needs a GUI click
&lt;/h3&gt;

&lt;p&gt;I wanted Remote Login on so I could ssh into this thing from my laptop. The classic CLI way is &lt;code&gt;sudo systemsetup -setremotelogin on&lt;/code&gt;. On macOS 13 and later that command will not work unless the calling binary has Full Disk Access. So even with sudo, it refuses.&lt;/p&gt;

&lt;p&gt;The fastest fix is the GUI toggle. Open System Settings, in the search box on the sidebar type &lt;strong&gt;Remote Login&lt;/strong&gt;, click the result, toggle it on. 15 seconds, done. Trying to fight it from the terminal is not worth the rabbit hole.&lt;/p&gt;

&lt;p&gt;This was the first time the setup made me reach for the trackpad. It will not be the last.&lt;/p&gt;

&lt;p&gt;With the OS pinned to "server mode" and SSH up, the box was ready to be reached from somewhere other than this desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2, Tailscale and the menu-bar trap
&lt;/h2&gt;

&lt;p&gt;For remote access I went with Tailscale. Free tier, WireGuard underneath, MagicDNS gives every device a name on my private network. From anywhere in the world I can reach this Mac mini the way I would reach &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Install was a one liner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; tailscale-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I tried to log in from the CLI. It failed. Reason, the standalone cask uses a System Extension and that extension needs user approval the very first time. Until you sign in once through the menu-bar app, the CLI is a paperweight.&lt;/p&gt;

&lt;p&gt;So I clicked the Tailscale icon in the menu bar, picked Log in, the browser opened, I signed in with my account, came back. Now &lt;code&gt;tailscale status&lt;/code&gt; showed the Mac mini on my tailnet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale status
tailscale ip &lt;span class="nt"&gt;-4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole device got a stable name like &lt;code&gt;mac-mini.tailXXXX.ts.net&lt;/code&gt; over MagicDNS. From now on, anywhere I am, I can reach this hostname as if the Mac mini was in the same room.&lt;/p&gt;

&lt;p&gt;Good. The perimeter was sorted. Time for the actual reason I bought into all this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3, the agent webhook
&lt;/h2&gt;

&lt;p&gt;Now the fun part. The agent army I had been dreaming about.&lt;/p&gt;

&lt;p&gt;The idea is simple. A small HTTP server on the Mac mini listens for a POST. When a POST comes in with a prompt, it spawns Claude Code as a subprocess, lets Claude do whatever the prompt asked, captures the output and returns it. Optionally it can also push the result to my phone.&lt;/p&gt;

&lt;p&gt;I kept this deliberately small. Python stdlib, no Flask, no FastAPI. About 150 lines of Python that does these three things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET /healthz&lt;/code&gt;, no auth, returns ok if the server is up.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /trigger&lt;/code&gt;, bearer-auth, body has a &lt;code&gt;prompt&lt;/code&gt; and a few optional knobs. Spawns &lt;code&gt;claude -p "&amp;lt;prompt&amp;gt;" --output-format json&lt;/code&gt;, returns the JSON output.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /notify&lt;/code&gt;, bearer-auth, posts to ntfy so the result lands on my phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The server binds to &lt;code&gt;127.0.0.1:8765&lt;/code&gt;, so it is only reachable from the Mac mini itself. Tailscale plus Caddy will expose it later if I want it from outside. The bearer token lives in &lt;code&gt;~/homelab/secrets/agent_token&lt;/code&gt;, mode 600, generated once with &lt;code&gt;openssl rand -hex 32&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To make it survive reboots I wrote a launchd plist at &lt;code&gt;~/Library/LaunchAgents/com.vineeth.agent.plist&lt;/code&gt; with &lt;code&gt;RunAtLoad=true&lt;/code&gt; and &lt;code&gt;KeepAlive=true&lt;/code&gt;. If the process dies, launchd brings it back. Logs go to &lt;code&gt;~/homelab/logs/agent.{log,err.log}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two CLI helpers that go on top:&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;# Fire a prompt, get JSON back.&lt;/span&gt;
agent &lt;span class="s2"&gt;"summarize what's on my calendar today"&lt;/span&gt;

&lt;span class="c"&gt;# Same, plus push the result to my phone.&lt;/span&gt;
agent &lt;span class="nt"&gt;--notify&lt;/span&gt; &lt;span class="s2"&gt;"scan ~/code/foo for security issues"&lt;/span&gt;

&lt;span class="c"&gt;# Liveness probe.&lt;/span&gt;
agent &lt;span class="nt"&gt;--healthz&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The healthz probe is the satisfying one. The first time it came back green, the agent army had its first soldier alive.&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.amazonaws.com%2Fuploads%2Farticles%2F629yn7ul9d841oq8x3ia.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.amazonaws.com%2Fuploads%2Farticles%2F629yn7ul9d841oq8x3ia.png" alt="Output of agent --healthz returning a JSON status of ok." width="355" height="335"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One nice surprise here. The agent does not need an Anthropic API key. It shells out to the &lt;code&gt;claude&lt;/code&gt; CLI as a subprocess, which uses whatever auth my local &lt;code&gt;claude&lt;/code&gt; CLI already has. Since I am logged into Claude Code on this Mac, the agent inherits that login. No separate billing line, no key rotation, no fuss. If I ever want an independent non-interactive key, I can add one to the plist, but for now this is enough.&lt;/p&gt;

&lt;p&gt;So the agent core was running. Now to put a friendly face in front of all this so my future self does not have to remember a port number for every service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4, Caddy, and the auto_https trap
&lt;/h2&gt;

&lt;p&gt;I wanted one nice landing page that lists all my homelab apps, and I wanted Caddy in front of everything as a reverse proxy. Caddy is great for this. One file, sensible defaults, internal certs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote a Caddyfile at &lt;code&gt;~/homelab/caddy/Caddyfile&lt;/code&gt;. The shape is a small gruvbox-themed landing page served at &lt;code&gt;/&lt;/code&gt; that links to every service, plus a reverse-proxy vhost per service. Starting with the vhost part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    auto_https off
}

vault.example.tailnet.ts.net {
    reverse_proxy 127.0.0.1:8222
}

uptime.example.tailnet.ts.net {
    reverse_proxy 127.0.0.1:3001
}

ntfy.example.tailnet.ts.net {
    reverse_proxy 127.0.0.1:8088
}

n8n.example.tailnet.ts.net {
    reverse_proxy 127.0.0.1:5678
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auto_https off&lt;/code&gt; because I assumed Tailscale would handle TLS and Caddy should just do plain HTTP. Started the service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew services start caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The four hosts came up on &lt;code&gt;:80&lt;/code&gt; but every request was returning the wrong thing. Some were dropping connections. Took me a minute to read the Caddy log.&lt;/p&gt;

&lt;p&gt;Found it. &lt;code&gt;auto_https off&lt;/code&gt; does not just disable the redirect from HTTP to HTTPS. It disables certificate provisioning too. So the four virtual hosts never got leaf certs and Caddy was confused about what to serve. The fix is one word.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    auto_https disable_redirects
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;disable_redirects&lt;/code&gt; keeps Caddy's internal cert management on, just stops it from auto-redirecting plain HTTP requests to HTTPS. That is exactly what I wanted. Reload Caddy, the four vhosts are healthy, Vaultwarden returns 200, Uptime Kuma returns 302.&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.amazonaws.com%2Fuploads%2Farticles%2Fliy5rpe0j7ou8l4cviro.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.amazonaws.com%2Fuploads%2Farticles%2Fliy5rpe0j7ou8l4cviro.png" alt="Caddy Caddyfile diff showing auto_https off replaced with auto_https disable_redirects, the one-word fix that unbroke cert provisioning." width="615" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the kind of thing you only learn by hitting it. The Caddy docs do say &lt;code&gt;auto_https off&lt;/code&gt; disables both, but I had not parsed that line carefully. One word, half an hour gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  MagicDNS does not resolve subdomain prefixes
&lt;/h3&gt;

&lt;p&gt;There was a second smaller catch right after. Tailscale MagicDNS gives each device a stable name like &lt;code&gt;mac-mini.tailXXXX.ts.net&lt;/code&gt;. What it does &lt;strong&gt;not&lt;/strong&gt; do, is resolve subdomain prefixes like &lt;code&gt;vault.tailXXXX.ts.net&lt;/code&gt;. So my Caddy vhosts named &lt;code&gt;vault.&amp;lt;tailnet&amp;gt;.ts.net&lt;/code&gt;, &lt;code&gt;uptime.&amp;lt;tailnet&amp;gt;.ts.net&lt;/code&gt;, etc, were not reachable from a phone or laptop on the tailnet, because the names did not resolve.&lt;/p&gt;

&lt;p&gt;The quick fix was to skip the pretty subdomains for now and just reach each service on the device hostname plus its port.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vaultwarden&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://mac-mini.tailXXXX.ts.net:8222&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uptime Kuma&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://mac-mini.tailXXXX.ts.net:3001&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ntfy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://mac-mini.tailXXXX.ts.net:8088&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://mac-mini.tailXXXX.ts.net:5678&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://mac-mini.tailXXXX.ts.net:8765&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These work over Tailscale's WireGuard tunnel so plain HTTP is fine inside the tailnet. The Caddy subdomain vhosts stay configured, but they are sitting unused until I decide to do &lt;code&gt;/etc/hosts&lt;/code&gt; entries on every client or move to Tailscale Serve. Which I did, but later in the story.&lt;/p&gt;

&lt;p&gt;Routing done for now. Time to stand up the actual things being routed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 5, the Docker stack via Colima
&lt;/h2&gt;

&lt;p&gt;Docker Desktop on macOS is a heavy beast. I went with &lt;strong&gt;Colima&lt;/strong&gt; instead, which gives you a Lima VM running Docker, all from the CLI, no GUI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;colima docker docker-compose
colima start &lt;span class="nt"&gt;--cpu&lt;/span&gt; 4 &lt;span class="nt"&gt;--memory&lt;/span&gt; 6 &lt;span class="nt"&gt;--disk&lt;/span&gt; 60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;4 CPUs, 6 GB RAM, 60 GB disk for the VM. That leaves enough for the Mac itself to breathe. To make sure Colima starts on every boot I wrote a small launchd plist that only calls &lt;code&gt;colima start&lt;/code&gt; if it is not already running, so it is idempotent.&lt;/p&gt;

&lt;p&gt;Then the actual stack. One docker-compose file at &lt;code&gt;~/homelab/stack/docker-compose.yml&lt;/code&gt;. All four containers bound to &lt;code&gt;127.0.0.1&lt;/code&gt; only, all with &lt;code&gt;restart: unless-stopped&lt;/code&gt;, all with their data directories under &lt;code&gt;~/homelab/data/&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vaultwarden&lt;/td&gt;
&lt;td&gt;8222&lt;/td&gt;
&lt;td&gt;Bitwarden compatible password manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uptime-kuma&lt;/td&gt;
&lt;td&gt;3001&lt;/td&gt;
&lt;td&gt;Service and endpoint uptime checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ntfy&lt;/td&gt;
&lt;td&gt;8088&lt;/td&gt;
&lt;td&gt;Push notifications to my phone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n&lt;/td&gt;
&lt;td&gt;5678&lt;/td&gt;
&lt;td&gt;Visual workflow automation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/homelab/stack
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four green.&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.amazonaws.com%2Fuploads%2Farticles%2Fnzy888ch716bbh9qs2oj.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.amazonaws.com%2Fuploads%2Farticles%2Fnzy888ch716bbh9qs2oj.png" alt="docker compose ps output showing vaultwarden, uptime-kuma, ntfy and n8n containers all up." width="800" height="195"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To update everything later, the two-line dance is &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;. Easy.&lt;/p&gt;

&lt;h3&gt;
  
  
  A macOS 15 thing worth knowing
&lt;/h3&gt;

&lt;p&gt;While testing, I noticed that hitting &lt;code&gt;http://&amp;lt;own-LAN-IP&amp;gt;:&amp;lt;port&amp;gt;&lt;/code&gt; from the same Mac was returning &lt;strong&gt;Empty reply from server&lt;/strong&gt;, even though TCP was accepting. This is a macOS 15 Local Network privacy behavior. It does not affect &lt;code&gt;127.0.0.1&lt;/code&gt; and it does not affect access over Tailscale, only the case where the Mac is talking to itself via its LAN IP. If I ever want LAN access from other devices in the house, the fix will be a permission grant in System Settings, Privacy and Security, Local Network. For now I do not need it because Tailscale handles all access from outside the Mac.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 6, ntfy and the topic mismatch
&lt;/h2&gt;

&lt;p&gt;ntfy is a tiny push-notification service. You publish to a topic, anyone subscribed to that topic gets a push. I run my own ntfy in Docker, so the topic name itself acts as the shared secret. Mine is a 16-character random hex string stored in &lt;code&gt;~/homelab/secrets/ntfy_topic&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The wrapper command is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;notify &lt;span class="s2"&gt;"title"&lt;/span&gt; &lt;span class="s2"&gt;"body"&lt;/span&gt;
notify &lt;span class="nt"&gt;--high&lt;/span&gt; &lt;span class="s2"&gt;"alert"&lt;/span&gt; &lt;span class="s2"&gt;"something bad just happened"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested the server side first by curling it directly. The server happily logged a publish event. So far so good.&lt;/p&gt;

&lt;p&gt;Then I installed the ntfy app on my phone, pointed it at the Tailscale URL, subscribed to my topic, and sent a test. Nothing. Phone was silent.&lt;/p&gt;

&lt;p&gt;The ntfy server log said &lt;code&gt;subscribers=1, topics_active=2&lt;/code&gt;. That number is what gave it away. Two active topics means the publisher and the subscriber are on different topic names. The phone was subscribed, but to a different string than what I was publishing to. Some small typo on my phone screen when I added the topic.&lt;/p&gt;

&lt;p&gt;I opened the ntfy app on the phone, long-pressed the subscription, edited the topic, pasted the exact 16-character hex string from &lt;code&gt;~/homelab/secrets/ntfy_topic&lt;/code&gt;. Tried again. Phone buzzed.&lt;/p&gt;

&lt;p&gt;Small bug, but it is the kind of thing that wastes 20 minutes if you trust the phone-side input.&lt;/p&gt;

&lt;p&gt;Notifications working. On to the bit I was most looking forward to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 7, Vaultwarden and the localhost-only crypto
&lt;/h2&gt;

&lt;p&gt;Now the moment I was looking forward to. Bringing up Vaultwarden, the Bitwarden compatible self-hosted password manager. I opened it from my laptop using the Tailscale URL, &lt;code&gt;http://mac-mini.tailXXXX.ts.net:8222&lt;/code&gt;. Clicked Create account. Got a browser error.&lt;/p&gt;

&lt;p&gt;Web crypto refuses to run on a non-HTTPS origin, except for &lt;code&gt;localhost&lt;/code&gt; and &lt;code&gt;127.0.0.1&lt;/code&gt;. Both of those are treated as secure contexts by browsers, so plain HTTP works there. But &lt;code&gt;mac-mini.tailXXXX.ts.net&lt;/code&gt; is not in that whitelist. So the signup form rendered, but the moment it tried to derive a key, the browser blocked it.&lt;/p&gt;

&lt;p&gt;The quick workaround was to do the signup directly on the Mac mini's own browser, hitting &lt;code&gt;http://127.0.0.1:8222&lt;/code&gt;. That worked first try because of the localhost exception. But this was not a permanent fix. I wanted to access Vaultwarden from my phone too. Plain HTTP over Tailscale would not be enough for that.&lt;/p&gt;

&lt;p&gt;The clean fix is &lt;strong&gt;Tailscale Serve&lt;/strong&gt;. Quick intro for anyone who has used Tailscale but not Serve. Serve is a built-in feature on top of plain Tailscale that lets you front a local port with real Let's Encrypt HTTPS on your &lt;code&gt;*.ts.net&lt;/code&gt; hostname. So instead of &lt;code&gt;http://mac-mini.tailXXXX.ts.net:8222&lt;/code&gt; you get &lt;code&gt;https://mac-mini.tailXXXX.ts.net/&lt;/code&gt;, with a publicly-trusted cert that any browser trusts, all still inside the WireGuard tunnel. No certbot, no &lt;code&gt;/etc/hosts&lt;/code&gt; tricks, no self-signed warnings.&lt;/p&gt;

&lt;p&gt;Serve needs two one-time toggles in the Tailscale admin console first. Enable Serve for the node, enable HTTPS certificates for the tailnet. Both are clicks in the admin web UI.&lt;/p&gt;

&lt;p&gt;After that I told Tailscale Serve to map ports.&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="nb"&gt;sudo &lt;/span&gt;tailscale serve &lt;span class="nt"&gt;--bg&lt;/span&gt; &lt;span class="nt"&gt;--https&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;443 http://127.0.0.1:8222   &lt;span class="c"&gt;# Vaultwarden&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale serve &lt;span class="nt"&gt;--bg&lt;/span&gt; &lt;span class="nt"&gt;--https&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8443 http://127.0.0.1:3001  &lt;span class="c"&gt;# Uptime Kuma&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale serve &lt;span class="nt"&gt;--bg&lt;/span&gt; &lt;span class="nt"&gt;--https&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000 http://127.0.0.1:8088 &lt;span class="c"&gt;# ntfy&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale serve &lt;span class="nt"&gt;--bg&lt;/span&gt; &lt;span class="nt"&gt;--https&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10001 http://127.0.0.1:5678 &lt;span class="c"&gt;# n8n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The port 443 squat
&lt;/h3&gt;

&lt;p&gt;The first command failed with &lt;code&gt;ERR_CONNECTION_CLOSED&lt;/code&gt; from the browser. Reason, Caddy was still listening on &lt;code&gt;:443&lt;/code&gt;. Tailscale Serve also wants &lt;code&gt;:443&lt;/code&gt;. They cannot share. So I took Caddy off &lt;code&gt;:443&lt;/code&gt; entirely (&lt;code&gt;auto_https disable_redirects&lt;/code&gt; keeps it on &lt;code&gt;:80&lt;/code&gt; only) and let Tailscale own all the HTTPS.&lt;/p&gt;

&lt;p&gt;After that the four services were on real HTTPS, with real certs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vaultwarden&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://mac-mini.tailXXXX.ts.net/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uptime Kuma&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://mac-mini.tailXXXX.ts.net:8443/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ntfy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://mac-mini.tailXXXX.ts.net:10000/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://mac-mini.tailXXXX.ts.net:10001/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I reloaded Vaultwarden on the tailnet URL, the browser was finally happy, signup went through.&lt;/p&gt;

&lt;p&gt;One housekeeping thing I did right after the first account, change &lt;code&gt;SIGNUPS_ALLOWED&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; in the compose file and bounce the container. Otherwise anyone who reaches the URL can sign up. Closing that door is a 10-second job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 8, Uptime Kuma and the HEAD vs GET trap
&lt;/h2&gt;

&lt;p&gt;Uptime Kuma was easy. Open the web UI on its port, walk through the first-run wizard, add monitors for the four services and the agent. There was one gotcha here too.&lt;/p&gt;

&lt;p&gt;By default, Uptime Kuma fires &lt;strong&gt;HEAD&lt;/strong&gt; requests for HTTP checks. ntfy's &lt;code&gt;/v1/health&lt;/code&gt; only answers &lt;strong&gt;GET&lt;/strong&gt;. HEAD returns 404. So Kuma was reporting ntfy as down, even though ntfy was perfectly fine.&lt;/p&gt;

&lt;p&gt;The fix is one dropdown change. Edit the monitor, scroll to HTTP Options, change Method from HEAD to GET. Save. Green within one heartbeat.&lt;/p&gt;

&lt;p&gt;While I was there I switched all the monitors to GET to keep them consistent, since some upstreams quietly return 200 on HEAD and some do not. Always-GET is safer for this kind of liveness check.&lt;/p&gt;

&lt;p&gt;The Kuma monitor list now has each service plus a ping to the Caddy landing page, all green. If anything goes red, Kuma is configured to push to ntfy, which means it pings my phone within a heartbeat. That closes the monitoring loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 9, restic backups to Backblaze B2
&lt;/h2&gt;

&lt;p&gt;The last brick. None of this homelab is useful if a power surge or a disk failure takes the data with it.&lt;/p&gt;

&lt;p&gt;I used &lt;strong&gt;restic&lt;/strong&gt; because I already know it inside out. I have been running it for backups for years, and at one point the cron-and-restic setup I had outgrew itself enough that I wrote my own NestJS backup service on top of it called &lt;a href="https://vineethnk.in/blog/building-backupctl" rel="noopener noreferrer"&gt;&lt;strong&gt;backupctl&lt;/strong&gt;&lt;/a&gt;. So picking restic for the homelab was the easy part. It is small, encrypts client-side, deduplicates, supports a bunch of remotes including Backblaze B2, and I know its quirks like a friend's bad jokes.&lt;/p&gt;

&lt;p&gt;For the destination I went with &lt;strong&gt;Backblaze B2&lt;/strong&gt;. At work we use a Hetzner Storage Box for company backups and it serves us very well, fixed monthly fee, plenty of space, predictable bill. The smallest one, BX11, is €3.20 a month for 1 TB. Cheap as chips at company scale.&lt;/p&gt;

&lt;p&gt;For a personal homelab the math is a bit different. I am pushing only a few hundred MB a night, and after restic dedup the whole thing stays well under 10 GB for the foreseeable future. B2's first 10 GB are free, and beyond that it is roughly seven dollars per terabyte per month. So for my actual usage right now, B2 lands at zero dollars a month. A fixed €3.20 on Hetzner would also be fine, it is not exactly going to bankrupt anyone, but free is free, and pay-as-you-go has the right shape for a side project that may or may not grow.&lt;/p&gt;

&lt;p&gt;If my homelab data ever crosses a terabyte, the math flips and Hetzner becomes the cheaper one. That is the day I will switch. For now, B2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;restic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wrapper script at &lt;code&gt;~/homelab/backup/backup.sh&lt;/code&gt; sources a secrets file with the B2 keys and the restic password, then runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic backup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/homelab/backup/excludes.txt &lt;span class="se"&gt;\&lt;/span&gt;
  ~/homelab ~/.zshrc ~/.gitconfig ~/.ssh/config

restic forget &lt;span class="nt"&gt;--prune&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--keep-daily&lt;/span&gt; 7 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--keep-weekly&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--keep-monthly&lt;/span&gt; 6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Excludes are the usual suspects, &lt;code&gt;node_modules&lt;/code&gt;, caches, log files, the env file itself.&lt;/p&gt;

&lt;p&gt;A launchd plist at &lt;code&gt;~/Library/LaunchAgents/com.vineeth.backup.plist&lt;/code&gt; runs this every day at 03:30. If the env file is missing or empty, the wrapper logs a polite message and exits 0, so the schedule does not crash on the days I have not yet configured credentials.&lt;/p&gt;

&lt;p&gt;For the credentials part I went to Backblaze, created a B2 account, made a private bucket, generated an application key with bucket-scoped read+write, copied the keyID, applicationKey, and the bucket name into &lt;code&gt;~/homelab/secrets/restic-env.sh&lt;/code&gt;. The restic password I generated locally with &lt;code&gt;openssl rand -base64 36&lt;/code&gt;, then wrote it down outside this Mac in two places, because if I lose this password, every snapshot becomes unrecoverable noise.&lt;/p&gt;

&lt;p&gt;First snapshot pushed in under a minute, 5.9 MiB in, 256 KiB out after dedup.&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.amazonaws.com%2Fuploads%2Farticles%2Fzdm0h16vvetu2oj21nhm.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.amazonaws.com%2Fuploads%2Farticles%2Fzdm0h16vvetu2oj21nhm.png" alt="restic snapshots output showing the first snapshot pushed to B2 with snapshot ID and size." width="800" height="477"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The retention rule keeps 7 daily, 4 weekly, 6 monthly. So at any time my B2 bucket has rolling coverage of the last week, the last month, and the last half year, with restic pruning the rest. Daily fire at 03:30 is wired up, future failures will push a high-priority ntfy notification to my phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I have, all together
&lt;/h2&gt;

&lt;p&gt;That is the homelab. One long evening that went past midnight, one Mac mini that used to live in a colleague's drawer, no SaaS bills.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vaultwarden&lt;/strong&gt; at &lt;code&gt;https://mac-mini.tailXXXX.ts.net/&lt;/code&gt;, my password vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uptime Kuma&lt;/strong&gt; at &lt;code&gt;:8443&lt;/code&gt;, watching my services and the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ntfy&lt;/strong&gt; at &lt;code&gt;:10000&lt;/code&gt;, pushing alerts and agent results to my phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n&lt;/strong&gt; at &lt;code&gt;:10001&lt;/code&gt;, where I will build workflow automations from now on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent webhook&lt;/strong&gt; at &lt;code&gt;:8765&lt;/code&gt;, runs Claude Code on demand from a phone shortcut, a cron job, or an n8n flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caddy&lt;/strong&gt; on &lt;code&gt;:80&lt;/code&gt;, serves a gruvbox themed landing page that lists everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netdata&lt;/strong&gt; on &lt;code&gt;:19999&lt;/code&gt;, machine-level monitoring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restic&lt;/strong&gt; to B2, daily at 03:30, 7-4-6 retention.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent army I had been dreaming about now has a barracks. The phone shortcut I wired up after this is one tap. Tap, speak the prompt, the Mac mini does the work, the result lands as a push notification on my phone. Pi could not carry that. Mac mini did not break a sweat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell anyone doing this
&lt;/h2&gt;

&lt;p&gt;A few things that would have saved me time if I had read them before starting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Mac will need GUI clicks.&lt;/strong&gt; macOS keeps tightening what the CLI can do without Full Disk Access. SSH Remote Login is one. Tailscale's System Extension is another. Plan for at least three or four short GUI sessions across the setup. Do not try to be a hero with &lt;code&gt;osascript&lt;/code&gt; workarounds, they break across versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tailscale is your perimeter.&lt;/strong&gt; Once Tailscale is up, your "internet-facing" attack surface drops to roughly zero. So you can stop pretending your local Caddy needs to be a hardened bastion, which is what frees you to keep the firewall permissive and the configs simple. Tailscale Serve on top gives you real HTTPS without certbot. That alone saves an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Colima, not Docker Desktop.&lt;/strong&gt; No GUI, no resource hog, no licensing question, starts via launchd. The Docker CLI works exactly the same as on Linux. The only thing you do not get is the Docker Desktop dashboard, which I do not miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One Caddyfile word matters.&lt;/strong&gt; &lt;code&gt;auto_https off&lt;/code&gt; is not the same as &lt;code&gt;auto_https disable_redirects&lt;/code&gt;. The first one kills certs. The second one keeps certs and only kills the HTTP-to-HTTPS redirect. Read it once carefully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HEAD vs GET will burn you once.&lt;/strong&gt; Default Uptime Kuma monitors use HEAD. Some health endpoints only answer GET. Set the method explicitly when you add a monitor, it saves a "why is this red" round trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Save the restic password offline before the first backup.&lt;/strong&gt; The first &lt;code&gt;restic backup&lt;/code&gt; initializes the encrypted repo and locks it to that password forever. Lose it and your snapshots are garbage. Paper, second password manager, anywhere that is not this same Mac.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes next
&lt;/h2&gt;

&lt;p&gt;The bones are in place. What I want to add on top, in roughly this order, are these.&lt;/p&gt;

&lt;p&gt;A small phone shortcut that POSTs to the agent webhook from the home screen, so firing a prompt is one tap and one voice dictation away. An n8n flow that emails me a daily brief at 07:00 from the same agent endpoint. A morning launchd job that runs &lt;code&gt;agent --notify "morning brief"&lt;/code&gt; so my phone wakes up with one notification instead of five.&lt;/p&gt;

&lt;p&gt;And the bigger one I am chewing on, a &lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt; in front of one or two of these services on my &lt;a href="https://vineethnk.in/blog/how-i-ended-up-buying-vinelabs-de" rel="noopener noreferrer"&gt;vinelabs.de&lt;/a&gt; domain. I already have that domain sitting there as my experiment lane, so something like &lt;code&gt;lab.vinelabs.de&lt;/code&gt; could front the agent or a public dashboard without ever opening a port on my router. Cloudflare Tunnel gives me a real public URL, real TLS, and Cloudflare Access on top if I want to gate it. The day I want any of this reachable from outside Tailscale, say from a friend's laptop or a colleague's phone, that is the path I will take.&lt;/p&gt;

&lt;p&gt;For now, the dream is no longer flapping its wings in vacuum. It has a roof, a barracks, and it sleeps in a 2018 Mac mini that a generous colleague handed me on a weekend, right in the middle of his own family time, without making a big deal of it. That kind of thing sticks.&lt;/p&gt;

&lt;p&gt;That is all I had for this one. If you have a Mac mini lying around, even an old one, give it a job. It will surprise you. Catch you in the next blog, take care until then.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>macmini</category>
      <category>tailscale</category>
      <category>docker</category>
    </item>
    <item>
      <title>I treated skills like dotfiles. Then they started spawning subagents.</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sun, 17 May 2026 09:50:05 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/i-treated-skills-like-dotfiles-then-they-started-spawning-subagents-5c64</link>
      <guid>https://dev.to/vineethnkrishnan/i-treated-skills-like-dotfiles-then-they-started-spawning-subagents-5c64</guid>
      <description>&lt;h1&gt;
  
  
  I treated skills like dotfiles. Then they started spawning subagents.
&lt;/h1&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.amazonaws.com%2Fuploads%2Farticles%2Fnkfnrj8r1a2h9d5sj0qc.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.amazonaws.com%2Fuploads%2Farticles%2Fnkfnrj8r1a2h9d5sj0qc.png" alt="A row of cartoon config files on a shelf, one of them sprouting little arms and calling three small helper robots through a glowing portal, flat illustration, soft colors, modern editorial style." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: My CLAUDE.md was turning into a Frankenstein .bashrc. Universal rules and one-off behaviours mashed together into a config file that kept growing by a few lines every week. Then Claude Code skills landed and my first instinct was, oh, these are basically dotfiles for Claude. The instinct was half right. Skills do behave like dotfiles in how they load. Opinionated, intent-triggered, version-controlled. But they go one step further. Skills can dispatch work to subagents. A dotfile sits there. A well-shaped skill knows how to recruit help when the task is bigger than one prompt. Dotfiles never did that. Skills go to work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So let me back up a little.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CLAUDE.md that ate itself
&lt;/h2&gt;

&lt;p&gt;A while back I noticed my CLAUDE.md had quietly become the longest config file in my home directory. Not the most-edited. Not the most-read. Just the longest. Every hard lesson I had paid for in production ended up there. The schema-verification protocol I built after one too many "column does not exist" errors. The pre-merge audit checklist that catches regressions in a diff. The refactor playbook that forces every rename to trace its call sites before touching code. Every time a bug came back to bite me, I would tab over to that file and tack on the guardrail I wished had been in place.&lt;/p&gt;

&lt;p&gt;Some sections genuinely belonged there. A rule like "never assume, verify before you respond or troubleshoot" is exactly the kind of always-on guardrail you want every session to start with. But other sections were not like that. The schema-verification protocol was a long, layered procedure with its own decision tree, fallback commands, and known gotchas for the legacy database. I do not need that loaded when I am writing prose or wiring up a frontend component. I need it loaded the moment I am about to touch a query. Different moment, different need.&lt;/p&gt;

&lt;p&gt;But CLAUDE.md does not care. CLAUDE.md is sourced every time. It is the .bashrc of the Claude world. Whatever you put in there shows up in every conversation, whether you wanted it there or not.&lt;/p&gt;

&lt;p&gt;If your config file has crossed a few hundred lines and you still keep adding to the bottom, you already know the shape of the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first instinct was dotfiles
&lt;/h2&gt;

&lt;p&gt;When skills landed in Claude Code, my first reaction was the same one I imagine most developers had. Oh nice, these are just dotfiles for Claude.&lt;/p&gt;

&lt;p&gt;The vibe is right. Skills are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opinionated.&lt;/strong&gt; They reflect how you write, how you commit, how you review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version-controlled.&lt;/strong&gt; They live in a folder. You can git track them. You can share them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intent-triggered.&lt;/strong&gt; A skill loads when it becomes relevant, not before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composable.&lt;/strong&gt; You install the ones you want, ignore the ones you do not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is dotfiles. That is exactly the model. The same way I keep a &lt;code&gt;.zshrc&lt;/code&gt; because I do not want to retype aliases on every machine I touch, I now reach for skills because I do not want to paste the same workflow rules every time I am about to do something specific.&lt;/p&gt;

&lt;p&gt;So I started moving things out of CLAUDE.md. The schema-verification protocol, with its full audit machinery and database-specific gotchas, became a verify-before-query skill. The pre-merge audit became a ship-readiness skill, complete with diff scanning, regression-pattern flagging, and a test-coverage gate. The refactor playbook, the one that traces every call site before allowing a rename, became its own skill. Slowly, CLAUDE.md shrank. The bloated file became leaner, and the sections that remained genuinely deserved to be always-on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split that worked
&lt;/h2&gt;

&lt;p&gt;After a few rounds of pulling things out, a pattern showed up. Two kinds of rules ended up in two different places.&lt;/p&gt;

&lt;p&gt;Always-on, universal rules stayed in CLAUDE.md:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Behavioural guardrails (verify before responding, ask when uncertain, do not invent APIs)&lt;/li&gt;
&lt;li&gt;Naming and coding conventions that apply to every file you write&lt;/li&gt;
&lt;li&gt;Testing standards that should be remembered any time tests are involved&lt;/li&gt;
&lt;li&gt;Quality gates that you want enforced regardless of task&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On-demand, situational rules moved into skills:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Schema-verification protocol (only matters when you are about to query or migrate a database)&lt;/li&gt;
&lt;li&gt;Pre-merge ship-readiness audit (only matters right before you push a branch)&lt;/li&gt;
&lt;li&gt;Refactor planning playbook that fans out call-site discovery (only matters when you are restructuring a module)&lt;/li&gt;
&lt;li&gt;Incident triage runbook with parallel log and metric scans (only matters when something is broken in production)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same way you keep &lt;code&gt;export PATH=...&lt;/code&gt; in your &lt;code&gt;.bashrc&lt;/code&gt; but you keep your CLI tools in &lt;code&gt;/usr/local/bin&lt;/code&gt; and only call them when the task asks for them. Universal stays at the entry point. Specific moves into discrete, callable units.&lt;/p&gt;

&lt;p&gt;Here is what that ends up looking like on disk, once you have done a few rounds of moving things out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.claude/
├── CLAUDE.md                       # the lean .bashrc - always loaded
└── skills/
    ├── verify-before-query/
    │   └── SKILL.md                # schema audit, fallback commands, gotchas
    ├── ship-readiness/
    │   ├── SKILL.md                # pre-merge checklist
    │   └── references/
    │       └── regression-patterns.md
    ├── refactor-plan/
    │   └── SKILL.md                # fans out call-site tracing
    └── incident-triage/
        ├── SKILL.md                # parallel log + metric scans
        └── references/
            └── known-fingerprints.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shape is the point. &lt;code&gt;CLAUDE.md&lt;/code&gt; sits at the top, lean and universal. Each skill is its own folder with its own &lt;code&gt;SKILL.md&lt;/code&gt; and its own supporting references, isolated from every other skill. Same mental model as &lt;code&gt;~/.config/&lt;/code&gt; or &lt;code&gt;/usr/local/bin/&lt;/code&gt;. You can &lt;code&gt;git&lt;/code&gt; track the whole tree, share it with a teammate, swap one skill out without touching the others.&lt;/p&gt;

&lt;p&gt;The split clicked for me when I noticed a section in my CLAUDE.md had been reduced to a one-liner that pointed to a skill, saying in effect "the rule is X, see the skill for the full rationale and the audit machinery." That single line is the analogy made literal. The always-on rule lives at the entry point. The heavy machinery lives in the skill that gets invoked when the situation calls for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the analogy broke
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fbje250ecq62u4k58b6be.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.amazonaws.com%2Fuploads%2Farticles%2Fbje250ecq62u4k58b6be.png" alt="A central glowing folder dispatching three small helper robots through separate portals to do parallel tasks, with a single report scroll returning to the center, flat illustration in soft pastel colors." width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I sat with the dotfiles-for-Claude framing for a bit and felt pretty pleased with myself. Then I noticed a difference I could not ignore.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;.bashrc&lt;/code&gt; is passive. It defines an alias and waits. The alias runs in your shell, in your process, in your context. It does not spin up another shell, hand it a task, get back a summary, and let you move on. That is a different category of thing.&lt;/p&gt;

&lt;p&gt;A skill can do exactly that. A well-shaped skill can fan out helpers for the parallelisable parts of a task. Tracing every call site of a function before approving a rename. Scanning a year of deploy logs against an error fingerprint during incident triage. Auditing a large diff against a long list of regression patterns before letting the merge go through. The grunt work runs on its own lane and comes back as a single report, while your main thread stays clean and ready to make the actual decision. That is not something a dotfile has ever done in the history of dotfiles.&lt;/p&gt;

&lt;p&gt;A dotfile is a snapshot of preferences. A skill is a snapshot of preferences plus a workflow. The workflow is yours to shape, and when the work is bigger than one prompt, it can call for backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters in practice
&lt;/h2&gt;

&lt;p&gt;Three things shifted in how I think about my AI setup once this clicked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One.&lt;/strong&gt; I stopped putting per-task rules into CLAUDE.md. Whenever I am about to add a section, I ask myself, is this rule something I want Claude to know every time, or only when I am doing a specific kind of task? If it is the second, it goes into a skill. CLAUDE.md stays close to what &lt;code&gt;.bashrc&lt;/code&gt; should be. Tight. Universal. Mostly stable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two.&lt;/strong&gt; I started thinking about skill-internal delegation as a first-class design choice. When tuning a skill, I now decide which steps should fan out and which should stay single-threaded. The research-heavy steps (call-site tracing, regression-pattern auditing, log scanning, dependency-tree walking) get dispatched to subagents. The judgement-heavy steps (choosing the migration strategy, picking which regressions are real, deciding what to ship) stay in the main thread. A skill is not just a prompt. It is a workflow, and a workflow can dispatch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three.&lt;/strong&gt; Sharing got more interesting. When you share a skill with a teammate, you are not just sharing your preferences. You are sharing a small workflow that knows how to recruit help when the task is bigger than itself. That changes the conversation. "Use my skill" becomes closer to "use my whole approach, including how it scales when the work is heavy."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the analogy gets uncomfortable
&lt;/h2&gt;

&lt;p&gt;I do not want to oversell this. The dotfiles framing is useful as an entry point, but it bends in a few places. Worth saying out loud.&lt;/p&gt;

&lt;p&gt;First, the always-on versus on-demand split is not as clean as I made it sound. Some sections still in my CLAUDE.md are arguably skill-shaped too. Stack-specific coding conventions. Framework patterns. API contracts. They only matter when I am working in that particular stack. They are not truly universal. A purist would extract them. I have not yet, partly because the boundary is fuzzy, partly because moving them out means trusting that the right skill activates at the right moment, and that trust takes time to build. Honest answer: this is a work in progress.&lt;/p&gt;

&lt;p&gt;Second, dotfiles are passive. They define. Skills are active. They can do. That is not a difference of degree, it is a difference of category. When you copy someone else's &lt;code&gt;.bashrc&lt;/code&gt;, the worst case is your shell behaves slightly weird until you remove the offending line. When you install someone else's skill, the worst case is much more interesting, because the skill might delegate to a subagent that runs code, opens PRs, or talks to your APIs. Skills come with more power, and therefore more responsibility for whoever is curating the list.&lt;/p&gt;

&lt;p&gt;Third, sharing skills across machines and teams is not yet a solved problem the way dotfiles are. There is no &lt;code&gt;stow&lt;/code&gt; for skills. No &lt;code&gt;chezmoi&lt;/code&gt; equivalent. No widely-adopted "here is my skill repo, symlink-mount it into &lt;code&gt;~/.claude/skills/&lt;/code&gt; and you are done" pattern. The ecosystem is still young. People are figuring it out as we go.&lt;/p&gt;

&lt;p&gt;So the right framing is closer to this. Dotfiles is the entry-point analogy. Once you internalise the analogy, you can start to see that skills are a strict superset. They borrow the dotfiles vibe and then add agency on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model I landed on
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fiqrdz21qqid43gm34tvv.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.amazonaws.com%2Fuploads%2Farticles%2Fiqrdz21qqid43gm34tvv.png" alt="Two cartoon vertical stacks side by side: a terminal-styled stack on the left with a config scroll on top and small toolbox icons below, mirrored by a robot-styled stack on the right with the same shape but glowing module folders, connected by a thin dotted arrow showing the parallel architecture, flat illustration in soft pastel colors." width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is how I now think about the whole stack.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.bashrc&lt;/code&gt; and CLAUDE.md are the same kind of object. Both are "load this every time I start a session." Both should be lean, opinionated, and universal. Both should grow rarely and shrink often.&lt;/p&gt;

&lt;p&gt;Skills and &lt;code&gt;/usr/local/bin/&amp;lt;tool&amp;gt;&lt;/code&gt; are the same kind of object. Both are "invoke this when the task calls for it." Both can do heavy work that does not belong in the rc file. Both can be shared, versioned, swapped out.&lt;/p&gt;

&lt;p&gt;The new piece, the part that makes skills not just dotfiles, is that skills can spawn subagents. Your tool in &lt;code&gt;/usr/local/bin/&lt;/code&gt; does not call another shell to delegate work back to itself. A skill can. That is the upgrade. That is why "dotfiles with agency" is a more accurate metaphor than "dotfiles for Claude."&lt;/p&gt;

&lt;p&gt;If you are still piling rules into CLAUDE.md and have not yet started moving the per-task ones out into skills, give the exercise a try. Even just the act of asking "is this rule universal, or is this for a specific moment?" sharpens your sense of what each layer is for. You may end up with a leaner CLAUDE.md, a few well-tuned skills, and a setup that finally has the kind of layered shape your terminal config has had for years.&lt;/p&gt;

&lt;p&gt;That is all I had on this one. If you made it till here, thank you, genuinely. See you in the next one, where I will probably be complaining about something else that broke.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>skills</category>
      <category>dotfiles</category>
      <category>developerworkflow</category>
    </item>
    <item>
      <title>How Git Worktrees Killed My Stash-Hotfix-Rebase Dance</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Fri, 15 May 2026 14:45:09 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/how-git-worktrees-killed-my-stash-hotfix-rebase-dance-2d20</link>
      <guid>https://dev.to/vineethnkrishnan/how-git-worktrees-killed-my-stash-hotfix-rebase-dance-2d20</guid>
      <description>&lt;h1&gt;
  
  
  How Git Worktrees Killed My Stash-Hotfix-Rebase Dance
&lt;/h1&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.amazonaws.com%2Fuploads%2Farticles%2Fkh0j7vc56bjwbayt3p5m.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.amazonaws.com%2Fuploads%2Farticles%2Fkh0j7vc56bjwbayt3p5m.png" alt="A developer calmly sipping coffee while three parallel laptops at branching desks each run their own task, flat illustration, soft colors, modern editorial style." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: For the longest time, every urgent hotfix in the middle of a feature meant the same painful little dance. Stash my work, checkout main, branch off, fix, push, switch back, rebase, pop the stash, then enjoy the surprise conflicts. Git worktrees made all of that nonsense vanish. One feature branch checked out in one folder, one hotfix branch checked out in another folder, both alive at the same time, both pointing at the same repo. Add agentic AI on top and now I am spinning up parallel worktrees, handing each one a task, and reviewing clean PRs in Graphite while my coffee is still warm. This blog is for every developer who has not yet befriended &lt;code&gt;git worktree&lt;/code&gt;. By the end of it, you will wonder how you survived without it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So before I tell you why worktrees changed my life, let me tell you why my life needed changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dance nobody asked for
&lt;/h2&gt;

&lt;p&gt;Picture the scene. I am deep into a feature branch. Files half-edited, mental model loaded, twenty browser tabs open, a debugger paused on a breakpoint I am about to investigate. The good kind of flow. The expensive kind.&lt;/p&gt;

&lt;p&gt;Then Slack does its little notification thing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Production is throwing 500s on the payment page. Can you take a quick look?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Of course I can. I am the on-call. So now begins the ritual. You know the one. Every developer who has ever held a git branch open during an incident knows the one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git stash push &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"wip feature stuff, please remember everything"&lt;/span&gt;
git checkout main
git pull
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; hotfix/payment-timeout
&lt;span class="c"&gt;# ... patch the bug, write a test, push, open PR, ship ...&lt;/span&gt;
git checkout feature/checkout-redesign
git rebase main
&lt;span class="c"&gt;# CONFLICT. of course.&lt;/span&gt;
git stash pop
&lt;span class="c"&gt;# CONFLICT. again. of course.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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.amazonaws.com%2Fuploads%2Farticles%2Forcgg81vvcyk4guk1iv0.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.amazonaws.com%2Fuploads%2Farticles%2Forcgg81vvcyk4guk1iv0.png" alt="A terminal full of git stash, checkout, rebase, conflict messages, the old hotfix dance." width="800" height="605"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By the time the stash pop ends in a second round of conflicts, the mental model I had carefully loaded into my head before the Slack ping has fully evaporated. The browser tabs are still there, but I have no idea why I had them open anymore. The breakpoint is irrelevant now because the file has been rewritten by the rebase. I have shipped the hotfix, sure. But I have also paid for it with the rest of my afternoon.&lt;/p&gt;

&lt;p&gt;You know this evening if you have ever lived it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I should have known earlier
&lt;/h2&gt;

&lt;p&gt;Here is the embarrassing part. &lt;code&gt;git worktree&lt;/code&gt; has been in git since version 2.5. That is from 2015. The feature is older than half the JavaScript frameworks people are arguing about on Twitter. And for a good chunk of my career, I never used it.&lt;/p&gt;

&lt;p&gt;The reason is simple. Nobody told me. The git tutorials I grew up on stopped at branch, merge, rebase, stash. Worktrees lived in the "advanced" page that nobody clicked. I want to fix that for you right here, before this blog ends.&lt;/p&gt;

&lt;p&gt;A worktree, in one sentence, is &lt;strong&gt;a second working directory for the same repo, with its own checked-out branch&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the whole idea.&lt;/p&gt;

&lt;p&gt;You know how a normal git repo has one folder where your files live, and you &lt;code&gt;git checkout&lt;/code&gt; to switch branches inside that folder? Worktrees say "what if you could have more than one such folder, each on a different branch, all sharing the same underlying repo data?"&lt;/p&gt;

&lt;p&gt;That is it. There is no magic. There is no parallel universe. There is no separate clone eating extra disk for a full second copy of history. Just one repo, multiple working directories, each on its own branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new dance, which is not really a dance
&lt;/h2&gt;

&lt;p&gt;So now the Slack ping comes in. I am still in my feature branch, still in flow. Here is what happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../app-hotfix &lt;span class="nt"&gt;-b&lt;/span&gt; hotfix/payment-timeout main
&lt;span class="nb"&gt;cd&lt;/span&gt; ../app-hotfix
&lt;span class="c"&gt;# patch, test, ship&lt;/span&gt;
git push origin hotfix/payment-timeout
&lt;span class="nb"&gt;cd&lt;/span&gt; ../app
&lt;span class="c"&gt;# back in my feature branch. nothing moved.&lt;/span&gt;
git worktree remove ../app-hotfix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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.amazonaws.com%2Fuploads%2Farticles%2Ffu11bm9vifc5o7378sq4.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.amazonaws.com%2Fuploads%2Farticles%2Ffu11bm9vifc5o7378sq4.png" alt="A clean terminal showing git worktree add, the hotfix workflow, and worktree remove." width="800" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No stash. No checkout dance. No rebase. No second conflict from popping a stash that no longer matches reality. My feature branch is exactly where I left it. The breakpoint is still paused. The browser tabs still make sense. The model is still loaded.&lt;/p&gt;

&lt;p&gt;This is not a productivity trick. This is a sanity trick.&lt;/p&gt;

&lt;p&gt;The first time I did this and switched back to my feature branch and saw my unsaved buffers exactly the way I had left them, I sat there and laughed at myself. All those years of stashing. All those evenings lost to conflict resolution. Gone, because of two flags on a command I had not bothered to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four worktree commands you actually need
&lt;/h2&gt;

&lt;p&gt;Worktrees sound exotic until you see how few commands run the whole show. There are basically four.&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;# 1. add a new worktree on a new branch&lt;/span&gt;
git worktree add ../path-to-new-folder &lt;span class="nt"&gt;-b&lt;/span&gt; new-branch-name base-branch

&lt;span class="c"&gt;# 2. add a worktree on an existing branch&lt;/span&gt;
git worktree add ../path-to-new-folder existing-branch-name

&lt;span class="c"&gt;# 3. list all your worktrees&lt;/span&gt;
git worktree list

&lt;span class="c"&gt;# 4. clean up a worktree when you are done&lt;/span&gt;
git worktree remove ../path-to-old-folder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole API. You will not need anything else for the first month. Maybe ever.&lt;/p&gt;

&lt;p&gt;A few things worth knowing that the man page mumbles instead of shouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each worktree gets its own checked-out branch, and a branch can only be checked out in one worktree at a time.&lt;/strong&gt; If your feature branch is checked out in &lt;code&gt;../app&lt;/code&gt;, you cannot also check it out in &lt;code&gt;../app-hotfix&lt;/code&gt;. Git will politely refuse. This is a feature, not a bug. It stops you from corrupting your own history by editing the same branch from two folders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worktrees share the same &lt;code&gt;.git&lt;/code&gt; data.&lt;/strong&gt; They do not duplicate your history. The new folder has a tiny &lt;code&gt;.git&lt;/code&gt; file that points back to the original repo. So disk usage is basically the size of your source tree, not the size of your history. Even for a monorepo with years of commits, adding a worktree costs you almost nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Branches you create inside a worktree are real branches in the main repo.&lt;/strong&gt; Push them, merge them, delete them. There is no "worktree branch" species. It is just a branch.&lt;/p&gt;

&lt;p&gt;If you have never tried this before and you are reading this on a workday, open your repo right now and run &lt;code&gt;git worktree add ../scratch -b throwaway main&lt;/code&gt;. Look at the new folder. Be impressed. Run &lt;code&gt;git worktree remove ../scratch&lt;/code&gt; when you are done. The whole experiment costs you nothing and teaches you everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this gets quietly powerful: agentic AI
&lt;/h2&gt;

&lt;p&gt;Now we get to the part that turned this from a nice habit into a discipline I will not work without.&lt;/p&gt;

&lt;p&gt;I have been heavy into AI-assisted development lately. Claude Code, Codex, whatever the agent of the month is. The pattern that finally clicked for me is this. Instead of pair-programming with the agent on one branch, I treat each agent like a junior colleague who needs their own desk.&lt;/p&gt;

&lt;p&gt;The desk is a worktree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../app-task-42 &lt;span class="nt"&gt;-b&lt;/span&gt; ai/refactor-auth main
git worktree add ../app-task-43 &lt;span class="nt"&gt;-b&lt;/span&gt; ai/upgrade-orval main
git worktree add ../app-task-44 &lt;span class="nt"&gt;-b&lt;/span&gt; ai/add-tracing  main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I open each worktree in its own terminal, kick off an agent in each one with a clear task, and walk away. Sometimes literally. Coffee, lunch, the school run.&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.amazonaws.com%2Fuploads%2Farticles%2F1u62ynzmvum9ulx4kogm.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.amazonaws.com%2Fuploads%2Farticles%2F1u62ynzmvum9ulx4kogm.png" alt="Three worktrees, three agents, three branches, all running in parallel." width="800" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When I come back, there are three branches. Sometimes three open PRs. Sometimes three half-done attempts where the agent got stuck on a question and is patiently waiting for me to unblock it. Either way, the worktrees never stepped on each other. Branch A did not corrupt branch B. The feature branch I was working on before I started this experiment is still there, untouched, sitting in its own folder, ready for me to pick up exactly where I left it.&lt;/p&gt;

&lt;p&gt;I review the PRs in Graphite. Stack them if they belong together. Merge them in the right order. The agent does the typing. I do the deciding. The worktrees are what make it parallel instead of a queue.&lt;/p&gt;

&lt;p&gt;Anyone else here doing this already and quietly grinning?&lt;/p&gt;

&lt;p&gt;The other thing worktrees give you in the AI workflow is something I did not expect. &lt;strong&gt;Review without context switching.&lt;/strong&gt; When one of the agents finishes a task, I do not need to abandon my own feature branch to review its PR. I just &lt;code&gt;cd&lt;/code&gt; into that worktree, read the diff, run the tests, decide. A short detour. Then &lt;code&gt;cd&lt;/code&gt; back to my own work and the model in my head is undisturbed.&lt;/p&gt;

&lt;p&gt;Compare that to the old way. Stash. Checkout to the PR branch. Run tests. Comment. Switch back. Pop. Pray. The cognitive cost of the old way was so high that I avoided reviewing PRs mid-feature. So either the reviews stacked up at the end of the day, or my own feature suffered. With worktrees, neither happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules I follow, which you can steal
&lt;/h2&gt;

&lt;p&gt;A few self-imposed rules that turned this from a sometimes-thing into a default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One worktree per intent.&lt;/strong&gt; Feature, hotfix, review, AI task. Each gets its own folder. If two efforts conceptually belong together, they share. If they do not, they do not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Name the folder after the task, not the branch.&lt;/strong&gt; &lt;code&gt;../app-hotfix&lt;/code&gt; is a folder. Inside it lives whichever hotfix branch I happen to be on at the moment. When the hotfix is shipped and the branch is dead, I can reuse the folder for the next hotfix. The folder is the desk. The branch is the paperwork on the desk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep them as siblings of the main repo, not inside it.&lt;/strong&gt; Putting a worktree inside the same folder as the main checkout confuses your editor, your file watchers, and your future self. A flat layout like &lt;code&gt;code/app&lt;/code&gt;, &lt;code&gt;code/app-hotfix&lt;/code&gt;, &lt;code&gt;code/app-task-42&lt;/code&gt; keeps everything sane.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete worktrees the moment they are done.&lt;/strong&gt; They are cheap to create. They should be cheap to destroy. A dead worktree lying around is exactly the kind of thing that quietly accumulates until someone, probably future you, has six folders and no memory of what is in any of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-worktree shell setup if your stack needs it.&lt;/strong&gt; If your project has a &lt;code&gt;.env&lt;/code&gt;, a &lt;code&gt;.tool-versions&lt;/code&gt;, or any per-folder setup, each worktree needs its own. This is usually a one-time copy and forget. Some teams put a tiny &lt;code&gt;bin/new-worktree&lt;/code&gt; script in the repo that does the setup automatically. Worth it if you do this often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three gotchas worth knowing upfront
&lt;/h2&gt;

&lt;p&gt;This is not all sunshine. Three things to watch out for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your editor does not always know what to do.&lt;/strong&gt; If you have a workspace open in your main folder and you also open the hotfix worktree in the same editor instance, some IDEs get confused about which &lt;code&gt;.git&lt;/code&gt; is which. I solved this by opening worktrees in fresh editor windows. Not a real problem, but worth knowing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Submodules can be funny.&lt;/strong&gt; If your repo uses submodules, each worktree needs to initialise its own submodule pointers. Read the man page section on this before assuming it will just work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tooling that hardcodes paths.&lt;/strong&gt; Some build tools, some Docker setups, some test runners have absolute-path assumptions baked in. The first time you run them in a worktree at a different absolute path, things may behave oddly. Usually a small fix in the config. Just be ready for it.&lt;/p&gt;

&lt;p&gt;None of these are dealbreakers. None of them have made me regret switching. But better you hear them from me than discover them at 2 in the morning during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on Graphite, because it deserves one
&lt;/h2&gt;

&lt;p&gt;I mentioned Graphite earlier without explaining it. If you do not use it, the short version is that it is a tool for managing stacks of pull requests. When you have multiple small PRs that depend on each other, Graphite makes them feel like one coherent change instead of a logistics nightmare.&lt;/p&gt;

&lt;p&gt;The combination of worktrees and Graphite is honestly the closest I have felt to having an actual second pair of hands. Worktrees give me parallel branches I can edit at the same time. Graphite gives me a way to review and ship those branches as a clean dependency chain. Together, they make the "many small focused PRs" school of working actually feasible, instead of the death-by-rebase it used to be.&lt;/p&gt;

&lt;p&gt;I am not affiliated. I just like things that work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to learn more
&lt;/h2&gt;

&lt;p&gt;If this blog made you want to actually understand worktrees properly, here is the small reading list I would have wanted when I started.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The official man page&lt;/strong&gt;. Honestly, just &lt;code&gt;man git-worktree&lt;/code&gt; in your terminal, or read it &lt;a href="https://git-scm.com/docs/git-worktree" rel="noopener noreferrer"&gt;online here&lt;/a&gt;. It is shorter than you expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The original announcement on the GitHub blog&lt;/strong&gt;. Worktrees landed in git 2.5 way back in 2015, and the &lt;a href="https://github.blog/open-source/git/git-2-5-including-multiple-worktrees-and-triangular-workflows/" rel="noopener noreferrer"&gt;release post&lt;/a&gt; is still one of the clearest explanations of why this feature exists and what problem it solves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-Erik Bergman's guide on Medium&lt;/strong&gt;. If the AI angle in this blog is what hooked you, &lt;a href="https://medium.com/@pererikbergman/the-ultimate-guide-to-git-worktrees-from-daily-dev-to-ai-agents-2b39e63a359d" rel="noopener noreferrer"&gt;his guide&lt;/a&gt; walks the same arc from daily dev use to coordinating parallel agents, in more depth than I have given it here. One thing I will flag: he recommends nesting worktrees inside a gitignored &lt;code&gt;.worktrees/&lt;/code&gt; folder at the repo root, which I disagree with for the file-watcher and &lt;code&gt;rm -rf&lt;/code&gt; reasons covered in the rules section above. Take the AI workflow ideas, skip the layout advice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitKraken's command walkthrough&lt;/strong&gt;. The &lt;a href="https://www.gitkraken.com/learn/git/git-worktree" rel="noopener noreferrer"&gt;GitKraken page on worktrees&lt;/a&gt; is the cleanest "show me add, list, remove" reference I have come across. Skip the GUI parts if you live in the terminal, the command examples stand on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own shell history&lt;/strong&gt;. I am only half joking. After you have used &lt;code&gt;git worktree add&lt;/code&gt; a few times, the muscle memory is the best teacher. Add a worktree to a throwaway repo today. Make a branch. Edit a file in it. Look at &lt;code&gt;git worktree list&lt;/code&gt;. A few minutes of hands-on beats any blog post, including this one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you read just one of these, read the man page. It is genuinely the fastest path from "I have heard of worktrees" to "I cannot believe I lived without these".&lt;/p&gt;

&lt;h2&gt;
  
  
  The discipline part of the title
&lt;/h2&gt;

&lt;p&gt;I called this a development discipline, not a trick. Let me explain why.&lt;/p&gt;

&lt;p&gt;A trick is something you reach for occasionally. A discipline is something you build your workflow around, so that the right thing is also the default thing.&lt;/p&gt;

&lt;p&gt;Worktrees only really pay off when you stop thinking of them as a tool for emergencies. They are how you organise simultaneous concerns. Feature in one. Hotfix in another. PR review in a third. AI experiment in a fourth. Each one has a desk. None of them step on the others. The cost of switching is &lt;code&gt;cd&lt;/code&gt;, which is the cheapest thing your shell can do.&lt;/p&gt;

&lt;p&gt;Once you operate this way, the old stash-checkout-rebase-pop dance starts to feel like something from a different era. Like writing CSS without a preprocessor. Or deploying without containers. The new way is so much calmer that the old way starts to seem actively user-hostile.&lt;/p&gt;

&lt;p&gt;That is when I knew it had become a discipline and not a trick. When I stopped reaching for stash. When my default response to an interrupt became "let me spin up a worktree" instead of "let me save what I have in some fragile way I hope I can restore later".&lt;/p&gt;

&lt;p&gt;If you take one thing from this blog, take that. Stop stashing. Start worktreeing. Your evenings will thank you.&lt;/p&gt;

&lt;p&gt;That is pretty much it from my side today. Let me know what you think, or if you have been through this exact stash-rebase-pop horror and never want to go back to it. Those stories are always the best ones. Catch you in the next blog.&lt;/p&gt;

</description>
      <category>git</category>
      <category>worktrees</category>
      <category>developerworkflow</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>Why My One-Line Installer Worked Everywhere Except WSL</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Fri, 15 May 2026 14:45:06 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/why-my-one-line-installer-worked-everywhere-except-wsl-44ab</link>
      <guid>https://dev.to/vineethnkrishnan/why-my-one-line-installer-worked-everywhere-except-wsl-44ab</guid>
      <description>&lt;h1&gt;
  
  
  Why My One-Line Installer Worked Everywhere Except WSL
&lt;/h1&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%2Fvineethnk.in%2Fblog%2Fwhy-my-one-line-installer-worked-everywhere-except-wsl-hero.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%2Fvineethnk.in%2Fblog%2Fwhy-my-one-line-installer-worked-everywhere-except-wsl-hero.png" alt="A puzzled cartoon developer between two laptops, one showing a green checkmark and one showing a red error, flat illustration, soft colors, modern editorial style."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: The application I work on used to take a new developer the better part of a week to set up. Some time back I added a Dockerfile and a &lt;code&gt;docker compose&lt;/code&gt; setup, so the whole onboarding became one command. Then microservices showed up, port conflicts followed, and the README started to grow again. So I built a proper one-line installer. &lt;code&gt;curl -fsSL https://app.our-product.com/install.sh | bash&lt;/code&gt;. Interactive, asks consent before installing missing deps, walks the user through port customisation, and uninstalls just as cleanly. It worked on every Mac. It worked on Linux. One developer on Windows tried it through WSL and got &lt;code&gt;./script.sh: 48: Syntax error: end of file unexpected (expecting "then")&lt;/code&gt; on a perfectly normal &lt;code&gt;if&lt;/code&gt; block. The script was fine. The bytes were not. The trail led to PowerShell's &lt;code&gt;curl&lt;/code&gt;, which is not curl, and a CRLF that snuck into every shell script in the pipeline. Strip the carriage returns at the top of the pipeline, or call &lt;code&gt;curl.exe&lt;/code&gt; directly, and the installer behaves itself on every platform.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So here is the longer version, because this is really a story about onboarding, and the installer is just the last chapter of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short history of setup pain
&lt;/h2&gt;

&lt;p&gt;For a good while, getting a new developer up and running on our application was a small ritual. The system used to be a self-hosted one, with all the joy that brings. Install this version of the language runtime. Install this exact version of the package manager. Install the database, with these flags. Run these migrations. Apt this. Brew that. Then accept all the dependency licence agreements one by one. By the time you reached the login page in your browser, the better part of a workweek was gone.&lt;/p&gt;

&lt;p&gt;I used to feel bad every time someone new joined. I mostly work remotely, so on the days I was on-site we would sit together at their desk with their fresh laptop, and on the other days we would slowly chew through the README over a Meet or a Huddle or a Teams call with screen-share, depending on which tool the team was using that quarter. Half the steps had silently rotted. The other half had hidden gotchas that only old hands knew. It was the kind of onboarding that quietly tells a new joiner "we do not really value your first impression". Not great.&lt;/p&gt;

&lt;p&gt;So a while back I sat down and wrote a Dockerfile, and a &lt;code&gt;docker-compose.yml&lt;/code&gt;, and a clear README on top of those. From that day on, new joiners ran one command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Schema migrations were optional and documented. The application came up. The login page worked. Onboarding compressed from days into one afternoon. For a while that felt like the win.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then microservices happened
&lt;/h2&gt;

&lt;p&gt;Some time later, the codebase grew into more than one service. Then more than two. Each new microservice came with its own compose file, its own ports, and its own opinions about what a sensible host port mapping looks like. And when two services both wanted the same host port, the second &lt;code&gt;docker compose up&lt;/code&gt; died loudly and the dev pinged me on Slack.&lt;/p&gt;

&lt;p&gt;I have written about that whole port-conflict mess separately, if you want the longer story of how we ended up settling it. The short version was, we stopped baking host ports into the committed compose files and started using a small override convention. Read &lt;a href="https://vineethnk.in/blog/docker-port-convention-suffix-vs-prefix/" rel="noopener noreferrer"&gt;why I stopped arguing about Docker port conventions&lt;/a&gt; for the full take. But even with that fixed, onboarding had quietly slid back into a multi-page README again. New devs had to read a checklist of which services to clone, which ones to bring up, which ones their machine needed dependencies for. The "one command" promise had eroded.&lt;/p&gt;

&lt;p&gt;So I sat down again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interactive one-line installer
&lt;/h2&gt;

&lt;p&gt;The plan was simple. Bring the onboarding back down to a single line. But this time, account for the fact that we have multiple services, multiple ecosystems, and machines that are configured slightly differently from each other.&lt;/p&gt;

&lt;p&gt;What I wanted was this.&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;-fsSL&lt;/span&gt; https://app.our-product.com/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire user-facing command. Everything else happens inside the installer, interactively. The script does roughly this.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detect the device.&lt;/strong&gt; OS, architecture, available shells, whether Docker is installed, whether the user is already on a working setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the requirements.&lt;/strong&gt; Walk through the list of things our stack needs. If any are missing, do not silently install them. Show what is missing and ask the user for consent, one by one. "Docker is not installed. Install it now? [y/N]". Same for Compose, same for the language runtime, same for the helper CLIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk through port customisation.&lt;/strong&gt; Show the default host ports for each service. Detect conflicts on the user's machine. If a port is taken, suggest a replacement and let the user override. Write the chosen ports into the local override file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bring the stack up.&lt;/strong&gt; All services, in the right order, with sensible defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Print the URLs.&lt;/strong&gt; "Open this in your browser, log in with these credentials." Done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is also a matching uninstaller that walks the reverse path. Stop the services, remove the containers and volumes, optionally remove the dependencies it installed earlier, leave the user's machine clean. The pair lives in the same repo, and the diff is the same shape as any other PR review.&lt;/p&gt;

&lt;p&gt;I shipped this. Most of the team is on Mac. The application runs on Ubuntu 24.04 in production. New devs on Mac ran the one-liner, said yes a few times, picked their ports, and were on the login screen in one short coffee. Old devs ran the uninstaller and reinstalled clean. The README shrank to one line of copy-paste.&lt;/p&gt;

&lt;p&gt;For a while it really felt like onboarding was solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one developer on Windows
&lt;/h2&gt;

&lt;p&gt;There was one holdout. One developer on the team is on Windows, and his microservices situation is genuinely different. The microservices stack on his end pulls in dependencies from a slightly different ecosystem, with its own package manager and its own setup steps. The Unix installer cannot do all of that work on a Windows host directly, because some of the tooling assumes a Unix shell underneath.&lt;/p&gt;

&lt;p&gt;I did not want to leave him behind. The whole point of the installer was that everyone on the team gets the same easy ride. "Everyone except the Windows developer" is not a one-liner. It is a politely worded form of exclusion.&lt;/p&gt;

&lt;p&gt;So I built a Windows wrapper. A small PowerShell script, &lt;code&gt;install.ps1&lt;/code&gt;, that does the Windows-side preparation. Make sure WSL is enabled. Make sure an Ubuntu distro is installed inside WSL. Pull in the Windows-side toolchain that the microservices need. Then, once WSL is ready, the PS1 wrapper just delegates to the Unix installer inside WSL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;irm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://app.our-product.com/install.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that in PowerShell. &lt;code&gt;irm&lt;/code&gt; is &lt;code&gt;Invoke-RestMethod&lt;/code&gt; and &lt;code&gt;iex&lt;/code&gt; is &lt;code&gt;Invoke-Expression&lt;/code&gt;. Together they fetch the PS1 from the server and run it in the current PowerShell session. The PS1 then sets the Windows world right. Then it reaches into WSL and runs the same Unix one-liner I shipped for everyone else. In theory, the Windows developer now lives the same life as a Mac developer. In practice...&lt;/p&gt;

&lt;h2&gt;
  
  
  The error that did not make sense
&lt;/h2&gt;

&lt;p&gt;He pinged me with a screenshot. The terminal had this.&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="go"&gt;./cli.sh: 48: Syntax error: end of file unexpected (expecting "then")
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line 48. Line 48 was a plain &lt;code&gt;if&lt;/code&gt; block. Three lines long. Looked like this.&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VERSION&lt;/span&gt;&lt;span class="s2"&gt;"&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="nv"&gt;VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"latest"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing fancy. No bashisms, no double brackets, just clean POSIX. The same lines were happily running on every Mac on the team and on the production Ubuntu fleet that same morning.&lt;/p&gt;

&lt;p&gt;I asked him to run it again. Same error. Same line. Plain Ubuntu inside WSL, fresh install, all defaults.&lt;/p&gt;

&lt;p&gt;And then he tried the other helper scripts. Same family of errors on every single one. Whichever shell script the PS1 wrapper ended up feeding into WSL, the parser choked on it. The pattern was suspicious. It was not one script. It was every shell script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong guesses I went through first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First guess. Old bash.&lt;/strong&gt; Maybe WSL ships an ancient bash and &lt;code&gt;if-then&lt;/code&gt; is being interpreted strangely. I asked for &lt;code&gt;bash --version&lt;/code&gt;. Bash 5.1. Same as my Mac. Dead end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second guess. Shell mismatch.&lt;/strong&gt; This was my most confident wrong guess. The one-liner pipes to &lt;code&gt;sh&lt;/code&gt;, and on Ubuntu, &lt;code&gt;/bin/sh&lt;/code&gt; is &lt;code&gt;dash&lt;/code&gt;, not bash. Dash is much fussier about bashisms. So if a bashism had quietly slipped into the script, only the dash machines would choke on it. But the same script ran cleanly on the Ubuntu server. And I ran it through dash directly on a Linux box of mine. No problem. So this theory died too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third guess. Weird WSL distro.&lt;/strong&gt; Maybe he had picked an Alpine variant or some musl-based thing where the system shell is mildly off. Turned out his default WSL distro was actually &lt;code&gt;docker-desktop&lt;/code&gt;, which is the stripped-down distro Docker Desktop ships for itself. Not really meant to be a daily-driver shell. So we changed his default WSL distro to plain Ubuntu using &lt;code&gt;wsl --set-default Ubuntu&lt;/code&gt;, made sure it was the fresh Microsoft Store one, and ran the installer again. Same error. Same line 48. So the distro was not the problem either, but at least now his terminal was a sensible place to live.&lt;/p&gt;

&lt;p&gt;I had eliminated all the reasonable explanations. The bug was still right there.&lt;/p&gt;

&lt;p&gt;Tell me I am not the only one who has been in this exact spot.&lt;/p&gt;

&lt;p&gt;So I gave up on guessing and asked him for a screen-share session. Sometimes the bug is not what you imagine. Sometimes you have to watch it happen on the actual machine where it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment the truth dropped
&lt;/h2&gt;

&lt;p&gt;Over the call, I asked him to skip the one-liner and instead download the script first, save it locally inside WSL, then run it.&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;-fsSL&lt;/span&gt; https://app.our-product.com/install.sh &lt;span class="nt"&gt;-o&lt;/span&gt; cli.sh
./cli.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same error. So the network step was fine. The script content was the actual problem.&lt;/p&gt;

&lt;p&gt;Then I asked him to run this.&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="nb"&gt;cat&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; cli.sh | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cat -A&lt;/code&gt; shows hidden characters. Where a normal Unix line ends with &lt;code&gt;$&lt;/code&gt;, a Windows line ends with &lt;code&gt;^M$&lt;/code&gt;. And the output that came back was full of &lt;code&gt;^M$&lt;/code&gt;. Every single line.&lt;/p&gt;

&lt;p&gt;That is when it clicked. And once I saw it on his machine, I knew it was going to be the same story on every other shell script the PS1 wrapper had touched.&lt;/p&gt;

&lt;p&gt;The script on his machine had Windows line endings. CRLF everywhere. &lt;code&gt;then\r\n&lt;/code&gt; instead of &lt;code&gt;then\n&lt;/code&gt;. To dash, and frankly to bash too, the word "then" followed by a carriage return is not the keyword &lt;code&gt;then&lt;/code&gt;. It is a six-character soup that happens to look like the word "then" if you ignore the &lt;code&gt;\r&lt;/code&gt;. The parser does not ignore the &lt;code&gt;\r&lt;/code&gt;. It looks for an actual &lt;code&gt;then&lt;/code&gt;, never finds one, walks off the end of the file, and reports "end of file unexpected (expecting then)" with the line number of the &lt;code&gt;if&lt;/code&gt; that started the block.&lt;/p&gt;

&lt;p&gt;The script was fine. The bytes were not. Something between the file on the server and the bytes that ended up inside WSL had decided to rewrite the line endings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual culprit: PowerShell's &lt;code&gt;curl&lt;/code&gt; is not curl
&lt;/h2&gt;

&lt;p&gt;This is the part I want every dev to know, because it bit me cleanly.&lt;/p&gt;

&lt;p&gt;In Windows PowerShell, &lt;code&gt;curl&lt;/code&gt; is not the curl you think it is. It is an alias for &lt;code&gt;Invoke-WebRequest&lt;/code&gt;. They are fundamentally different things. Real curl streams raw bytes from a URL to stdout. &lt;code&gt;Invoke-WebRequest&lt;/code&gt; returns a structured PowerShell object with headers, status, body, and the rest. When you pipe that object onward, PowerShell stringifies it. And one of PowerShell's choices when stringifying is "use native Windows line endings, because we are on Windows".&lt;/p&gt;

&lt;p&gt;The PS1 wrapper I had written did a lot of small things, but at the heart of it, for every shell script it had to pull from the server and hand over to WSL, it was effectively doing this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;curl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-fsSL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://app.our-product.com/install.sh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bash&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Innocent looking. Reads exactly like the Unix one-liner. But the &lt;code&gt;curl&lt;/code&gt; in there was the PowerShell alias, not real curl. The bytes that left it had been quietly converted from LF to CRLF on the way through. By the time &lt;code&gt;bash&lt;/code&gt; inside WSL saw the script, every line ended in &lt;code&gt;\r\n&lt;/code&gt;. Every if. Every then. Every case branch. And the helper scripts the installer kicks off internally have &lt;code&gt;#!/bin/sh&lt;/code&gt; shebangs, which means they get executed by &lt;code&gt;dash&lt;/code&gt; on Ubuntu. Dash is even less forgiving about &lt;code&gt;then\r&lt;/code&gt; than bash. That is why the error in the screenshot was the dash-flavoured one.&lt;/p&gt;

&lt;p&gt;The kicker is that none of us could have spotted this from reading either the PS1 or the shell script. Both files were fine. The transport was the problem. And the transport was lying about being curl.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, in three flavours
&lt;/h2&gt;

&lt;p&gt;I ended up shipping all three of these in different layers, because each one defends against a slightly different version of the same trap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flavour one. Tell PowerShell to use real curl.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Windows 10 and Windows 11 ship a real &lt;code&gt;curl.exe&lt;/code&gt;. So the fix inside the PS1 wrapper is to bypass the alias and call the executable directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;curl.exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-fsSL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://app.our-product.com/install.sh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bash&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;.exe&lt;/code&gt; is the whole difference. It tells PowerShell "no, do not give me your fake curl, give me the actual binary that ships with Windows". The bytes pass through unchanged. LF stays LF. The script runs.&lt;/p&gt;

&lt;p&gt;This was the first thing I changed inside the wrapper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flavour two. Defend in the pipeline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You cannot trust every future maintainer to remember the &lt;code&gt;.exe&lt;/code&gt;. So I also changed the pipeline to strip carriage returns before handing bytes to the shell.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;curl.exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-fsSL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://app.our-product.com/install.sh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tr -d '\r' | bash"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tr -d '\r'&lt;/code&gt; removes every &lt;code&gt;\r&lt;/code&gt; byte from the stream. If the upstream curl was real curl, this is a no-op. If something later breaks and a CRLF sneaks back in from a different source, this quietly fixes it before the shell ever sees it. Belt and braces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flavour three. Defend inside the script.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For people who download the script first and then run it locally, which is the careful thing to do, the pipeline fix does not help them. So I added a small self-heal at the top of the installer itself.&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;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-eu&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'\r'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&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;"Detected Windows line endings, normalising and re-running..."&lt;/span&gt;
  &lt;span class="nv"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\r'&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;chmod&lt;/span&gt; +x &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# rest of the installer below this&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first thing the script does is check itself for carriage returns. If it finds any, it writes a CRLF-free copy to a temp file and re-executes that copy with the same arguments. The user sees one extra line of output, and the install continues like nothing happened.&lt;/p&gt;

&lt;p&gt;You can argue this is too clever for an installer. I would normally agree. But the whole job of an installer is to absorb platform weirdness so the user does not have to. If the cost of doing that is six lines at the top of the script, I will pay six lines every day of the week.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one more, while we are here
&lt;/h2&gt;

&lt;p&gt;I also added a &lt;code&gt;.gitattributes&lt;/code&gt; rule to the repo, because the same trap has a sibling that bites at checkout time rather than at transport time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*.sh text eol=lf
*.bash text eol=lf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells git that no matter what platform the repo gets checked out on, shell scripts get LF endings on disk. Windows machines with &lt;code&gt;core.autocrlf=true&lt;/code&gt;, which is the Windows default, will still hand you LF for these files. It does not solve the PowerShell &lt;code&gt;curl&lt;/code&gt; problem because that one happens in transport, not at checkout. But it stops a different version of the same trap from biting any future dev who clones the repo on the Windows filesystem and then tries to run scripts from inside WSL.&lt;/p&gt;

&lt;p&gt;Same shape of bug. Different point in the pipeline. Better to defend both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we landed
&lt;/h2&gt;

&lt;p&gt;After the screen-share session ended, the Windows developer ran the PS1 one-liner again. WSL was already set up from the earlier failed attempt. PowerShell now used real curl. The pipeline normalised line endings just in case. The shell scripts self-healed if they ever saw a CR. All of his microservices, including the ones on the other ecosystem, came up. He saw the login page in his browser. The whole thing took a coffee, the same as everyone else.&lt;/p&gt;

&lt;p&gt;He pinged me later that day to say it was the smoothest setup he had ever done on a Windows machine for a real engineering project. Coming from someone who has spent years working around the seams between Windows and Linux tooling, that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would have done differently
&lt;/h2&gt;

&lt;p&gt;With hindsight, the very first thing I should have checked was line endings. Whenever a shell script behaves differently across platforms, and there is no obvious bash-versus-dash issue, the next thing to look at is the bytes. It is almost always line endings. I lost a good chunk of an afternoon to version checks and dash compatibility tests before I got there.&lt;/p&gt;

&lt;p&gt;I also should have written the PS1 wrapper with &lt;code&gt;curl.exe&lt;/code&gt; from day one, instead of using whatever &lt;code&gt;curl&lt;/code&gt; happened to resolve to in PowerShell. The alias is a footgun and the fix is six characters.&lt;/p&gt;

&lt;p&gt;The bigger lesson though is one I knew but had not really internalised. In a "modern one-line installer", the line that does the most work is not the line that runs the install. It is the line that gets your script's bytes from the server to the user's shell without corruption. That step is invisible. That step also has the most ways to silently go wrong, and it does not care that the script is correct. If the bytes are off by one carriage return, all the careful code in the world will not save you.&lt;/p&gt;

&lt;p&gt;So now the installer assumes nothing about the transport. It uses real curl. It strips &lt;code&gt;\r&lt;/code&gt; in the pipeline. It normalises itself if it sees CRLF inside. And the repo carries a &lt;code&gt;.gitattributes&lt;/code&gt; rule for good measure. The Mac devs are unaffected. The Linux servers are unaffected. The Windows developer has the same one-command onboarding as the rest of the team.&lt;/p&gt;

&lt;p&gt;Not going to pretend this was a perfect writeup. But if even one part of it helped some other developer avoid the afternoon I lost, then it was worth putting down. See you in the next one.&lt;/p&gt;

</description>
      <category>shellscripting</category>
      <category>wsl</category>
      <category>windows</category>
      <category>installer</category>
    </item>
    <item>
      <title>How I ended up buying vinelabs.de</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sun, 10 May 2026 17:47:53 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/how-i-ended-up-buying-vinelabsde-50l9</link>
      <guid>https://dev.to/vineethnkrishnan/how-i-ended-up-buying-vinelabsde-50l9</guid>
      <description>&lt;h1&gt;
  
  
  How I ended up buying vinelabs.de
&lt;/h1&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.amazonaws.com%2Fuploads%2Farticles%2Fewh7nkrtxh3yge07kja7.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.amazonaws.com%2Fuploads%2Farticles%2Fewh7nkrtxh3yge07kja7.png" alt="A hand pinning a small green leaf flag onto a desk globe pointing at Germany, flat illustration, soft colors, modern editorial style." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I bought &lt;code&gt;vinelabs.de&lt;/code&gt; last weekend. Was not planning to. The trigger was the author field of a manifest file, the same kind you fill into a &lt;code&gt;composer.json&lt;/code&gt;, a &lt;code&gt;package.json&lt;/code&gt;, a &lt;code&gt;Cargo.toml&lt;/code&gt;, or whatever your stack of the day calls it. The realisation was that shipping serious packages under my personal GitHub username reads like a hobby for code that will sit in someone's finance pipeline. Trust problem, not a code problem. So I bought a domain. Set up an org. Built a small landing site. Here is the short version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So here is what happened. I was in the middle of finishing up &lt;code&gt;xrechnung-kit&lt;/code&gt;, which started as a small Shopware plugin and grew into a monorepo with eight packages. I have already written about that one separately, so if you want &lt;a href="https://vineethnk.in/blog/the-shopware-plugin-that-grew-into-a-library/" rel="noopener noreferrer"&gt;the long story you can find it here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But the boring scene that mattered was this. I was filling in the manifest files for the Shopware sibling package and the small Astro showcase site that was going to live next to it. So the &lt;code&gt;composer.json&lt;/code&gt; for the PHP package on one side, the &lt;code&gt;package.json&lt;/code&gt; for the site on the other. I got to the author block, and I paused. The whole list of packages at that point was going to live under &lt;code&gt;vineethkrishnan/xrechnung-kit-*&lt;/code&gt; on Packagist, and the showcase site under my personal GitHub username too. All in my personal namespace. For a library that will sit inside finance and accounting pipelines, the vibe of "github.com/vineethkrishnan/anything" reads as hobby. Even if the code is solid. Even if the tests pass. The address itself does the talking before the code gets a chance to.&lt;/p&gt;

&lt;p&gt;That was a trust problem, not a code problem. I needed a brand.&lt;/p&gt;

&lt;p&gt;If you have ever flinched while writing your own name into a &lt;code&gt;composer.json&lt;/code&gt;, a &lt;code&gt;package.json&lt;/code&gt;, or whatever manifest your stack uses, for a package you actually want people to take seriously, you know exactly what I mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shortlist that did not happen
&lt;/h2&gt;

&lt;p&gt;I sat for a bit with name options. The first instinct was, of course, &lt;code&gt;.com&lt;/code&gt;. Tried &lt;code&gt;vinelabs.com&lt;/code&gt;. Already taken. Looked at &lt;code&gt;vinelabs.io&lt;/code&gt; and &lt;code&gt;vinelabs.app&lt;/code&gt; next, the standard "labs" fallbacks people reach for.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;.de&lt;/code&gt; had been in the back of my head the whole time, and I will tell you why.&lt;/p&gt;

&lt;p&gt;I have been working in German work culture for a long while now. Handled many &lt;code&gt;.de&lt;/code&gt; domains across many German shops. Shopware itself is German-scoped. The first XRechnung use case is German. EN 16931 is a EU thing, but XRechnung 3.0 is a federal German standard. If the projects I am putting under this brand are going to focus on the DE and EU region, which they will, then &lt;code&gt;.de&lt;/code&gt; is not a quirky choice. It is the home address.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;vinelabs.de&lt;/code&gt;. Bought it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I set up
&lt;/h2&gt;

&lt;p&gt;The bare minimum to make a brand feel real, in order:&lt;/p&gt;

&lt;p&gt;The org &lt;code&gt;github.com/vinelabs-de&lt;/code&gt;. This is where the public-facing repos live.&lt;/p&gt;

&lt;p&gt;Two mailboxes, &lt;code&gt;info@vinelabs.de&lt;/code&gt; and &lt;code&gt;support@vinelabs.de&lt;/code&gt;. Forwarded to where they need to go. Nothing fancy.&lt;/p&gt;

&lt;p&gt;A small landing site, Astro 5 + Tailwind v4, deployed to Cloudflare Pages. The site is driven by a markdown content collection at &lt;code&gt;src/content/projects/&lt;/code&gt;. Every project I want to showcase is one markdown file with a tagline, a description, a license, and a few highlights. New project equals new file. There is no CMS, no admin panel, no database. I keep saying this about Astro to anyone who will listen, but Astro continues to be unreasonably nice when you do not need a backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why now, and why DE
&lt;/h2&gt;

&lt;p&gt;The timing is not accidental. Germany is right in the middle of phasing in mandatory B2B e-invoicing. The receive-side mandate is already live, and the send-side mandate is rolling out behind it. EN 16931 / XRechnung 3.0 is what has to come out the other end. A small library that does that correctly, sitting under a brand that is clearly in the DE / EU lane, has a place.&lt;/p&gt;

&lt;p&gt;I should also be clear about who I am here. I am an Indian developer, not a German one. I have been working with German teams and German shops for a long time, picked up a fair bit of the working culture, handled enough .de domains and Shopware shops to feel at home in this stack. But I am not pretending to be local. The brand is in the DE / EU lane because that is where the work is, not because I am putting on a costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mirror trick
&lt;/h2&gt;

&lt;p&gt;Here is the part I am quietly pleased about. I did not want to actually move my repos out of my personal GitHub account. That account has my history, my issues, my CI configurations, my settings. I did not want a hard fork, a rename, or a redirect.&lt;/p&gt;

&lt;p&gt;So I wrote a tiny workflow template, &lt;code&gt;mirror-to-vinelabs.yml&lt;/code&gt;. Lives in a &lt;code&gt;workflow-templates/&lt;/code&gt; folder. I drop it into any of my personal repos, and on every push to main it syncs that repo into the &lt;code&gt;vinelabs-de&lt;/code&gt; org.&lt;/p&gt;

&lt;p&gt;My personal repo stays the source of truth. The labs org stays the public face. If I ever pull out of the labs branding, it costs me nothing because the canonical code never moved. It is already wired up for &lt;code&gt;xrechnung-kit&lt;/code&gt;. &lt;code&gt;vaultctl&lt;/code&gt; is next, then probably a couple of the smaller tools that have outgrown my personal username.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;I do not have a roadmap. There is no team. There is no monetisation plan. No funding round, no big launch.&lt;/p&gt;

&lt;p&gt;The labs domain exists because I would rather under-promise on a brand than over-promise on my own name. &lt;code&gt;xrechnung-kit&lt;/code&gt; deserved a home that says "this is built to be used", not "this is what one developer made on a long weekend." It did start on a long weekend. What it is not going to stay is a weekend project. I plan to maintain it like something that has to keep working.&lt;/p&gt;

&lt;p&gt;The V is a stem. Everything else is what grew off it.&lt;/p&gt;

&lt;p&gt;Alright, that is me done rambling for today. Hope something in here was useful to you. Catch you in the next blog, take care until then.&lt;/p&gt;

</description>
      <category>personal</category>
      <category>branding</category>
      <category>astro</category>
      <category>cloudflarepages</category>
    </item>
    <item>
      <title>The disk that filled itself</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Thu, 07 May 2026 15:44:29 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/the-disk-that-filled-itself-2649</link>
      <guid>https://dev.to/vineethnkrishnan/the-disk-that-filled-itself-2649</guid>
      <description>&lt;h1&gt;
  
  
  The disk that filled itself
&lt;/h1&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%2Fvineethnk.in%2Fblog%2Fthe-disk-that-filled-itself-hero.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%2Fvineethnk.in%2Fblog%2Fthe-disk-that-filled-itself-hero.png" alt="A hard drive cabinet with its door open showing mostly empty shelves, an external gauge on the outside reading 100 percent full in red, a small ghost icon hovering near one of the shelves to hint at invisible files. Flat illustration, soft muted colors, modern editorial style."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: my homelab box hit 100 percent disk full out of nowhere. I deleted half the things I could find, &lt;code&gt;df&lt;/code&gt; still said full, &lt;code&gt;du&lt;/code&gt; said I had plenty of space. Turned out the disk was holding on to files I had already deleted, because a long-running process still had them open. &lt;code&gt;lsof +L1&lt;/code&gt; was the magic. A service restart was the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So there I was, on a perfectly normal evening, ssh'd into the homelab box because something had stopped responding. The first thing I check on any "why is this dying" run is &lt;code&gt;df -h&lt;/code&gt;, almost as a reflex.&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="go"&gt;Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme0n1p2  450G  448G   2G  100% /
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cool. So that is why nothing is working.&lt;/p&gt;

&lt;p&gt;I have a deal with this box. It runs my self-hosted things, it does not ask for much, and once a quarter or so I prune some old container images and we move on. So I went straight to the usual cleanup playbook, mildly annoyed that I had let it fill up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker system prune &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--volumes&lt;/span&gt;
journalctl &lt;span class="nt"&gt;--vacuum-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200M
apt clean
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; ~/.cache/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Felt good. Watched the percentages tick down in &lt;code&gt;du&lt;/code&gt; as I went. Ran &lt;code&gt;df -h&lt;/code&gt; again, full of optimism.&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="go"&gt;/dev/nvme0n1p2  450G  448G   2G  100% /
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Excuse me?&lt;/p&gt;

&lt;h2&gt;
  
  
  When df and du disagree
&lt;/h2&gt;

&lt;p&gt;I went and added it up the long way. &lt;code&gt;du -sh /&lt;/code&gt; took its time, came back with about 130G used. Big folders identified, nothing weird. Half the disk should have been free.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;df&lt;/code&gt; sat there, smug, telling me I had two whole gigabytes of breathing room. Same disk. Same minute.&lt;/p&gt;

&lt;p&gt;This is the moment in any disk-full story when you realise the problem is not actually the disk. It is who is asking.&lt;/p&gt;

&lt;p&gt;If you have hit this exact mismatch before, you already know where this is going. If you have not, here is the thing that took me longer to internalise than I want to admit: &lt;code&gt;df&lt;/code&gt; and &lt;code&gt;du&lt;/code&gt; are not measuring the same thing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;du&lt;/code&gt; walks the directory tree. It adds up files it can see, file by file. If a file is not in some directory, &lt;code&gt;du&lt;/code&gt; does not know it exists.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;df&lt;/code&gt; asks the filesystem itself how many blocks are in use. The filesystem does not care about directories. It cares about which blocks have been handed out to a file, any file, anywhere.&lt;/p&gt;

&lt;p&gt;Most of the time these two views agree. The interesting case is when they do not. And the most common reason they disagree is files that are not in any directory but are still very much being used.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deleted file that is not deleted
&lt;/h2&gt;

&lt;p&gt;In Linux, &lt;code&gt;rm&lt;/code&gt; does not actually delete a file. It just removes the entry from a directory. The file's data only goes away when the last process holding it open lets go.&lt;/p&gt;

&lt;p&gt;Which means: if a process has a log file open, and you &lt;code&gt;rm&lt;/code&gt; that log file, the directory entry is gone, &lt;code&gt;du&lt;/code&gt; cannot see it, your file browser shows it as deleted, you are happy. But the process is still writing to it. The blocks are still held. &lt;code&gt;df&lt;/code&gt; is still counting them.&lt;/p&gt;

&lt;p&gt;Until that process closes the file or dies, those bytes are real, just invisible.&lt;/p&gt;

&lt;p&gt;This is the part of Linux that feels like a magic trick once you see it. &lt;code&gt;lsof&lt;/code&gt; exposes it directly.&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="nb"&gt;sudo &lt;/span&gt;lsof +L1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+L1&lt;/code&gt; means "show me files with a link count less than 1", which is exactly the deleted-but-still-held case. I ran it expecting maybe a couple of stray MB. The output was a wall of text. The same process kept showing up, holding a frankly embarrassing number of "deleted" files.&lt;/p&gt;

&lt;p&gt;The culprit was not exotic. It was the docker daemon, sitting on a container's &lt;code&gt;json-file&lt;/code&gt; log that had ballooned to hundreds of gigs across the time the box had been running. Some time back, in a cleanup session I do not really remember anymore, I had &lt;code&gt;rm&lt;/code&gt;'d that log file directly, thinking I was reclaiming space. Docker had no idea I had done that. The file was gone from disk as far as I was concerned. Not gone from docker's open file descriptor.&lt;/p&gt;

&lt;p&gt;So every byte that container had been logging since that day, plus every byte before, was still there. Held. Counted by &lt;code&gt;df&lt;/code&gt;. Invisible to &lt;code&gt;du&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Tell me I am not the only one who has done this exact "smart" cleanup move and quietly made it worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the not-fix
&lt;/h2&gt;

&lt;p&gt;The actual fix was embarrassing in its simplicity.&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="nb"&gt;sudo &lt;/span&gt;systemctl restart docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. The daemon restarted, every file descriptor it was holding got closed, every "deleted" file finally got a chance to be properly deleted, and &lt;code&gt;df&lt;/code&gt; was suddenly back to a sensible number.&lt;/p&gt;

&lt;p&gt;The not-fix, the thing I should have done in the first place to avoid this whole thing, would have been to never &lt;code&gt;rm&lt;/code&gt; an active log file. The right move on a docker container log is to truncate it through the existing file descriptor.&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="nb"&gt;truncate&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 0 /var/lib/docker/containers/&amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/&amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;-json&lt;/span&gt;.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;truncate&lt;/code&gt; writes through the file descriptor instead of unlinking the directory entry. Docker keeps writing. Disk space comes back. Nobody gets confused.&lt;/p&gt;

&lt;p&gt;Or, even better, configure the json-file log driver with &lt;code&gt;max-size&lt;/code&gt; and &lt;code&gt;max-file&lt;/code&gt; so it rotates itself and you never have this conversation.&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;"log-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"log-opts"&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;"max-size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"100m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That goes in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;, you restart the daemon once, and then this whole class of bug stops being a thing on that box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tools I built so I do not have to do this manually again
&lt;/h2&gt;

&lt;p&gt;After this exact kind of incident, and the embarrassing number of &lt;code&gt;du -sh /*&lt;/code&gt; sessions that came before it, I went and built a few small things to take the manual labour out of disk-full nights. They are the tools I now reach for before I touch anything by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dfree&lt;/code&gt;&lt;/strong&gt; is the first one I run. It is a shell script. No arguments, no flags to remember. It scans the disk in a few passes and shows me what is taking space across docker, system caches, dev caches, and logs. Same playbook I tried to do by hand at the start of this story, except it adds the numbers correctly and shows me the docker side first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ dfree

=== System Analysis ===

[INFO] Scanning disk usage...
500G 448G 2G 100%

[INFO] Scanning Docker usage...
Images: 18.2GB (12.4GB reclaimable)
Containers: 287GB (281GB reclaimable)
Build Cache: 4.1GB

[INFO] Scanning Developer Caches...
  - /home/vineeth/.cache: 480MB
  - /home/vineeth/.npm/_cacache: 1.1GB

[INFO] Scanning Logs...
  - /var/log/journal: 320MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the docker line. &lt;code&gt;Containers: 287GB (281GB reclaimable)&lt;/code&gt;. On the actual night this happened, I could have read that one line and known exactly where the trouble was, without going on a &lt;code&gt;find&lt;/code&gt; expedition. After the analysis, dfree asks me one item at a time what I want cleaned, and I say yes or no.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== Cleanup Process ===

Prune Docker system (images, containers, networks)? [y/N] y
[INFO] Pruning Docker...
Total reclaimed space: 12.4GB

Clean system cache at /var/log/journal? [y/N] y
Clean developer cache at /home/vineeth/.npm/_cacache? [y/N] y

[SUCCESS] Cleanup complete.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For when a flat list is not enough and I want to actually see the shape of the disk, I built &lt;strong&gt;&lt;code&gt;diskdoc&lt;/code&gt;&lt;/strong&gt;, a Rust TUI that walks the filesystem in parallel and lets me browse the result like a tree. Useful when the offender is buried somewhere weird and I want to wander through the directory structure instead of reading a summary. It is not what saves you on the night of. It is what saves you the third time you keep ending up in the same neighbourhood and want to understand why.&lt;/p&gt;

&lt;p&gt;But the tool that would have actually short-circuited this whole post is &lt;strong&gt;&lt;code&gt;dockit&lt;/code&gt;&lt;/strong&gt;, a Go CLI that talks to the docker daemon directly. It has a &lt;code&gt;logs&lt;/code&gt; subcommand built for this exact failure mode.&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;dockit logs
&lt;span class="go"&gt;Finding container log paths on disk...

--- CONTAINER LOG SIZES (Total: 287 GB) ---
CONTAINER            SIZE            WARNINGS
notes-app            287 GB          🚨 EXCESSIVE - Consider adding 'log-opt max-size=10m'
nextcloud            42 MB
gitea                8.3 MB
media-server         2.1 MB
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That first row is the entire war story compressed into one line. One container, no rotation, hundreds of gigabytes of json sitting on disk, and the tool literally tells me what to do about it. If I had been running &lt;code&gt;dockit logs&lt;/code&gt; on a cron and getting a ping when any single container crossed a sensible threshold, none of this would have happened. The investigation would have been "fix the log driver config" months ago, not "why is my disk lying to me" at midnight.&lt;/p&gt;

&lt;p&gt;If you want the tools, all three are open source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dfree:&lt;/strong&gt; &lt;a href="https://github.com/vineethkrishnan/dfree" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/dfree&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;diskdoc:&lt;/strong&gt; &lt;a href="https://github.com/vineethkrishnan/diskdoc" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/diskdoc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dockit:&lt;/strong&gt; &lt;a href="https://github.com/vineethkrishnan/dockit" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/dockit&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two lessons I keep relearning
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;df&lt;/code&gt; and &lt;code&gt;du&lt;/code&gt; measure two different worlds.&lt;/strong&gt; When they agree, life is easy. When they disagree, the answer is almost always "something is being held open". &lt;code&gt;lsof +L1&lt;/code&gt; is the single command that tells you exactly what. I have probably typed it a hundred times in my career and I still forget it exists for the first stretch of every disk-full incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;rm&lt;/code&gt; on an active log file is a trap.&lt;/strong&gt; It looks like cleanup. It is actually just hiding bytes from &lt;code&gt;du&lt;/code&gt; while the process keeps appending to invisible disk. Use &lt;code&gt;truncate&lt;/code&gt; if the process supports being truncated under it, signal the process to reopen its log if the app supports that, or rotate properly with logrotate or the platform's native rotation.&lt;/p&gt;

&lt;p&gt;Early on in this incident, I was completely sure I had simply not deleted enough stuff yet. I was a few minutes away from ordering another drive. The fix was a service restart, and the cause was a &lt;code&gt;rm&lt;/code&gt; from months ago that I had thought was helpful at the time.&lt;/p&gt;

&lt;p&gt;If you have an old box with self-hosted things on it and you have ever cleaned up a "huge log file" by deleting it directly, today is a good day to run &lt;code&gt;sudo lsof +L1&lt;/code&gt; and see what your processes are still holding. Worst case you find nothing. Best case you find a sizeable chunk of your disk waiting to be freed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The thing that bothers me about this kind of bug is not the bug itself. It is that I had a wrong mental model of &lt;code&gt;rm&lt;/code&gt; for years and never really noticed, because most of the time the wrong model and the right model produce the same result. The penalty only shows up at the edges, in long-lived processes with open files, on a box you have neglected for long enough that you forget what you did last summer.&lt;/p&gt;

&lt;p&gt;So that is where I will stop. If you have a different way of catching this kind of thing earlier, or a cleaner way of dealing with active logs on a homelab box, I genuinely want to hear it, drop me a note. Otherwise, see you when the next interesting problem shows up.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>linux</category>
      <category>diskfull</category>
      <category>docker</category>
    </item>
    <item>
      <title>MCP is the USB-C of AI tools, and most devs are still using their AI assistant like it is 2023</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Thu, 07 May 2026 13:17:44 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/mcp-is-the-usb-c-of-ai-tools-and-most-devs-are-still-using-their-ai-assistant-like-it-is-2023-5bpn</link>
      <guid>https://dev.to/vineethnkrishnan/mcp-is-the-usb-c-of-ai-tools-and-most-devs-are-still-using-their-ai-assistant-like-it-is-2023-5bpn</guid>
      <description>&lt;h1&gt;
  
  
  MCP is the USB-C of AI tools, and most devs are still using their AI assistant like it is 2023
&lt;/h1&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.amazonaws.com%2Fuploads%2Farticles%2F8qf59z5p4dd9996hlwoy.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.amazonaws.com%2Fuploads%2Farticles%2F8qf59z5p4dd9996hlwoy.png" alt="A single USB-C cable in the middle of a desk with thin glowing wires fanning out to small floating app icons - chat, calendar, notes, design canvas, code editor." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So here is a small thing I noticed the other day. I was watching a friend debug a production issue, and the workflow was painful in a very specific way. Tab to their AI chat of choice, paste an error. Read the answer. Tab to Sentry, copy the stack trace. Tab back to the chat, paste the stack trace. Tab to the codebase, copy the function. Paste it again. Repeat until coffee gets cold. It honestly does not matter which AI they were using. ChatGPT, Claude, Codex, Gemini, take your pick. The flow was the same.&lt;/p&gt;

&lt;p&gt;The whole thing felt like watching someone use a phone in 2010. Functional. Slow. And clearly a generation behind something that already exists.&lt;/p&gt;

&lt;p&gt;That is the gap I want to talk about today. Because there is a very real protocol shift happening in AI tooling right now, and most developers are completely unaware of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cable drawer in your house
&lt;/h2&gt;

&lt;p&gt;Open the drawer where you keep your old chargers. Go on, I will wait.&lt;/p&gt;

&lt;p&gt;If you are anywhere over thirty, you probably have a small museum in there. Mini USB. Micro USB. The old Apple 30-pin. Lightning. That one weird Samsung cable that nobody can identify. A barrel charger from a router you threw away in 2014. Each one was the only way to talk to a specific device. Each one was useless for anything else.&lt;/p&gt;

&lt;p&gt;USB-C did not appear and instantly fix the world. It just slowly became the one cable that worked for everything. Laptop, phone, headphones, monitor, the toothbrush my wife uses, my Kindle. One connector. No drawer.&lt;/p&gt;

&lt;p&gt;AI tooling is going through the exact same moment right now. Most people have not noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drawer of integrations
&lt;/h2&gt;

&lt;p&gt;For the last couple of years, every AI integration was its own custom cable.&lt;/p&gt;

&lt;p&gt;You wanted your AI assistant to read your Notion? Cool, here is a custom plugin that runs on that vendor's plugin system, with its own auth, its own schema, its own quirks. You wanted a different model to query your database? Different system. You wanted to do something with Slack? Build a function-calling wrapper, write the schema by hand, host it somewhere, deal with the auth yourself. You wanted to switch from ChatGPT to Claude, or Claude to Codex, or any of them to a local model? Throw all of it away and start over.&lt;/p&gt;

&lt;p&gt;Every "AI integration" was bespoke. Every developer who built one had to figure out the same five problems from scratch. Auth. Schema. Transport. Tool descriptions. Error handling. Five problems times one hundred SaaS tools times five model vendors gives you a number that should have scared us all.&lt;/p&gt;

&lt;p&gt;And then a small thing called the &lt;strong&gt;Model Context Protocol&lt;/strong&gt; showed up and said: what if this was just one shape?&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually is
&lt;/h2&gt;

&lt;p&gt;I will keep this short because the spec is honestly not that interesting and you can read it later if you want.&lt;/p&gt;

&lt;p&gt;MCP is a protocol. Your AI client (Claude, ChatGPT, Codex, Gemini, Cursor, whoever) speaks one shape. Any tool, any service, any local script can implement that shape and the client can talk to it. The client does not care if it is reading from Notion, posting to Slack, querying Postgres, or running a Playwright browser. They all expose the same kind of interface. Tools, resources, prompts. That is basically the whole story.&lt;/p&gt;

&lt;p&gt;The cleverness is not in the protocol design. The cleverness is in the agreement. Anthropic shipped it. OpenAI adopted it. The big SaaS companies started writing servers for their own products. Atlassian has one. Figma has one. Slack has one. Notion. Vercel. Gmail. Google Calendar. Playwright. The list is now embarrassing in length.&lt;/p&gt;

&lt;p&gt;It is the same thing USB-C did. Not a technical breakthrough. A standardisation moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;Here is what my actual day looks like now, and I want to be honest, this is the part that took me a while to internalise.&lt;/p&gt;

&lt;p&gt;When something breaks in production, I open my editor. I do not open Sentry. I do not open Notion. I do not switch tabs. I just say something like, &lt;em&gt;"pull the latest unresolved issue in the api project, show me the stack trace, and tell me which file it points to"&lt;/em&gt;. The agent calls the Sentry MCP, gets the issue, reads the file from the codebase, and tells me where the bug is. Sometimes it offers a fix. Sometimes I tell it to write the fix and resolve the issue. The whole loop, including writing the patch and closing the ticket, lives in one window.&lt;/p&gt;

&lt;p&gt;And that is for one tool. The same agent, in the same session, can also pull a Linear ticket, check a Figma frame, post an update to Slack, query a Postgres database, and run a quick Playwright test against staging. All without me leaving the editor.&lt;/p&gt;

&lt;p&gt;Compare that to the friend I mentioned at the start. Tab to chat, paste, copy, paste, copy. Same problem. Different decade. And again, it is not about which AI tool they picked. ChatGPT, Claude, Codex, Gemini, all of them now speak MCP or are in the process of adding it. The bottleneck is not the model. The bottleneck is whether you have actually plugged anything into it.&lt;/p&gt;

&lt;p&gt;Tell me I am not the only one who finds this gap funny.&lt;/p&gt;

&lt;h2&gt;
  
  
  I built a thing because I felt the pain
&lt;/h2&gt;

&lt;p&gt;A while back I started building MCP servers for the SaaS tools I actually use at work. It started with one. Then two. Then before I knew it I had eleven of them, plus a shared OAuth library, plus a docs site, plus a Docker setup so they would show up properly in the public registries. The repo is called &lt;a href="https://github.com/vineethkrishnan/mcp-pool" rel="noopener noreferrer"&gt;mcp-pool&lt;/a&gt; and I wrote a &lt;a href="https://vineethnk.in/blog/building-mcp-pool" rel="noopener noreferrer"&gt;whole separate post&lt;/a&gt; about how it grew, so I will not retell that story here.&lt;/p&gt;

&lt;p&gt;The thing I want to point out is that the painful part was never writing the servers. The SDKs are decent. The protocol is small. You can scaffold a basic server in an afternoon if you have done it once before.&lt;/p&gt;

&lt;p&gt;The painful part was running them. Six different Node processes on my machine, each one with its own config file, each one needing its own auth token, each one occasionally crashing for no reason and silently disappearing from the agent's tool list. That is the part nobody warns you about. Once you have more than two or three MCP servers, the operations side starts to look a lot like running a small fleet of microservices on your own laptop. Which, when you put it that way, is kind of an absurd thing to be doing.&lt;/p&gt;

&lt;p&gt;But that is the price of being early. Same way the first USB-C laptops needed three dongles in your bag. The protocol was right. The ecosystem was still catching up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2023 dev versus the 2026 dev
&lt;/h2&gt;

&lt;p&gt;So here is the bit I keep coming back to.&lt;/p&gt;

&lt;p&gt;The 2023 developer treats the language model as a smarter Stack Overflow. You type a question. You read the answer. You copy something out. You paste it into your code. Your context lives in the chat window. The model has no memory of your repo, your team, your tools, your tickets, your design files, your runbooks, anything.&lt;/p&gt;

&lt;p&gt;The 2026 developer treats the language model as the centre of a small workshop. The model has access to the actual systems. It can read the ticket. Open the file. Run the test. Check the design. Post the update. Close the ticket. The dev is no longer copy-pasting context in. The dev is just describing what they want done, and the agent is fetching, reading, deciding, writing.&lt;/p&gt;

&lt;p&gt;This is not about AI being smarter. It is about AI being plugged in.&lt;/p&gt;

&lt;p&gt;And I would gently suggest that if you are still in the first group, you are leaving an embarrassing amount of productivity on the table. Not because you are bad at your job, but because you are using a 2023 workflow on a 2026 toolchain. Same way someone might still be charging their phone with a cable they keep in a drawer with seven other cables.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit nobody is putting on the marketing slide
&lt;/h2&gt;

&lt;p&gt;So far this post has been mostly cheerful. A new protocol, a nicer way to work, a cable drawer that finally got cleaned up. Honest moment now.&lt;/p&gt;

&lt;p&gt;Plugging more tools into your AI assistant is also plugging more attack surface into your daily workflow. The MCP ecosystem has had a genuinely rough run on the security front, and if you are about to install a few servers this weekend, you should know what has actually happened in the last year before you do it.&lt;/p&gt;

&lt;p&gt;A short and very much not comprehensive list of real incidents (the &lt;a href="https://authzed.com/blog/timeline-mcp-breaches" rel="noopener noreferrer"&gt;authzed MCP breach timeline&lt;/a&gt; has the fuller version, and is what I cross-checked these against):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;April 2025, &lt;a href="https://invariantlabs.ai/blog/whatsapp-mcp-exploited" rel="noopener noreferrer"&gt;WhatsApp MCP&lt;/a&gt;&lt;/strong&gt;: a tool-poisoning attack disguised a backdoor as a legitimate server and quietly exfiltrated chat histories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;May 2025, &lt;a href="https://invariantlabs.ai/blog/mcp-github-vulnerability" rel="noopener noreferrer"&gt;GitHub MCP&lt;/a&gt;&lt;/strong&gt;: a prompt injection in a malicious public issue hijacked the agent into leaking private repository contents, using a token whose scope was way too broad.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;September 2025, &lt;a href="https://thehackernews.com/2025/09/first-malicious-mcp-server-found.html" rel="noopener noreferrer"&gt;Postmark MCP&lt;/a&gt;&lt;/strong&gt;: a trojanized package on a public registry was BCC-ing every email it handled to attacker infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;October 2025, &lt;a href="https://blog.gitguardian.com/breaking-mcp-server-hosting/" rel="noopener noreferrer"&gt;Smithery Registry&lt;/a&gt;&lt;/strong&gt;: a path traversal bug exposed builder credentials and compromised thousands of hosted MCP servers in one go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;April 2026, &lt;a href="https://thehackernews.com/2026/04/anthropic-mcp-design-vulnerability.html" rel="noopener noreferrer"&gt;core MCP STDIO design flaw&lt;/a&gt;&lt;/strong&gt;: an architectural decision in Anthropic's official SDKs that, depending on who you read, exposes upwards of a hundred and fifty million downloads across Cursor, VS Code, Windsurf, Claude Code and others.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And right next to this, a related incident that was not strictly an MCP breach but is exactly the pattern you should be watching for. In April 2026, &lt;strong&gt;Vercel&lt;/strong&gt; &lt;a href="https://vercel.com/kb/bulletin/vercel-april-2026-security-incident" rel="noopener noreferrer"&gt;disclosed&lt;/a&gt; that an employee was compromised through &lt;strong&gt;Context.ai&lt;/strong&gt;, a third-party AI tool that held a Google Workspace OAuth app with broad permissions. Malware on the AI vendor's laptop, then OAuth pivot, then into Vercel customer environment variables (&lt;a href="https://techcrunch.com/2026/04/20/app-host-vercel-confirms-security-incident-says-customer-data-was-stolen-via-breach-at-context-ai/" rel="noopener noreferrer"&gt;TechCrunch&lt;/a&gt; and &lt;a href="https://www.trendmicro.com/en_us/research/26/d/vercel-breach-oauth-supply-chain.html" rel="noopener noreferrer"&gt;Trend Micro&lt;/a&gt; have the cleanest writeups). Not MCP-specific. But the shape is exactly the shape MCP makes more common.&lt;/p&gt;

&lt;p&gt;The pattern across all of these is the same. An AI tool sits in the middle of your stack, holding tokens that reach into your real systems. If that tool is malicious, vulnerable, or just sloppily run, the blast radius is whatever those tokens can reach. And tokens for "read my Notion" or "post to Slack" are not low-privilege things in 2026. They are basically the keys to an entire workspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually check if an MCP server is safe for you
&lt;/h2&gt;

&lt;p&gt;This is not a perfect checklist. It is the rough rubric I run before I install a server. Steal it, sharpen it, throw it away, whatever works.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who publishes it.&lt;/strong&gt; Is the server from the SaaS vendor whose API it wraps, from a known community maintainer, or from a username you have never seen before? Vendor-official is safest. A maintainer with a real track record is fine. A brand new account with one package and no GitHub history is a hard no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the source.&lt;/strong&gt; Most MCP servers are small. Cloning the repo and skimming the tool list takes a few minutes. Look at what tools are exposed, what their descriptions actually say, and whether anything is doing something the README does not mention. Tool poisoning lives in exactly this gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the dependency tree.&lt;/strong&gt; A small wrapper with two hundred transitive dependencies is a very different risk profile from a small wrapper with five. Shorter is better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token scope, ruthlessly.&lt;/strong&gt; When you generate the token the server will use, give it the smallest set of permissions that gets the job done. Read-only beats read-write. Single-project beats organisation-wide. Single-channel beats whole-workspace. Never reuse a token you already use somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run it locally, not on a hosted gateway.&lt;/strong&gt; Hosted MCP gateways are convenient. They are also a single point at which someone else is holding your credentials. If a server can run as a local stdio process on your own machine, prefer that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only first, write tools opt-in.&lt;/strong&gt; If the server supports read-only mode, start there. Only enable write tools after you have used it long enough to trust both the server and how the agent behaves with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for updates that change tool descriptions.&lt;/strong&gt; This is one of the sneakier attack patterns. A server you trusted last month silently expands its tool descriptions in this week's update to include something new and harmful. Pin versions if you can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the registry verification badges.&lt;/strong&gt; Glama and the official MCP registry now flag servers that have been smoke-tested. Not perfect signal, but a server with zero badges, zero stars, and no recent commits is at least worth a second look.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a server fails most of these, do not install it. If it fails one or two, decide whether the convenience is worth it for your specific situation. None of this is paranoia. It is the same hygiene most of us already apply to npm packages, just adapted to a newer ecosystem that is still figuring out the basics.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell a friend
&lt;/h2&gt;

&lt;p&gt;If you read this far and you are wondering whether to bother, here is what I would actually say to a friend over coffee.&lt;/p&gt;

&lt;p&gt;Pick one tool you use every day. One. Sentry, Notion, Linear, Slack, your database, whatever. Find an existing MCP server for it on GitHub, or look at the official ones from Anthropic, or check &lt;code&gt;mcp-pool&lt;/code&gt; if any of those line up with your stack. Run the safety checklist above before you install. Then wire it into Claude Desktop or Claude Code or your client of choice. Spend a single evening doing this and nothing else.&lt;/p&gt;

&lt;p&gt;The first time you say &lt;em&gt;"summarise the last five Sentry issues from this morning"&lt;/em&gt; and an actual answer comes back, with real data, from the real system, you will get it. The shift will feel obvious in hindsight. You will wonder how you spent so long copy-pasting things into a chat box.&lt;/p&gt;

&lt;p&gt;That is basically the whole point of this post. Not "MCP is cool". Not "here are the seven best servers to install today". Just: a thing has changed, and most people I know in tech have not yet noticed it has changed. Which is normal. Standardisation moments are always quiet. The drawer of cables does not announce itself. One day you just notice you have not opened the drawer in years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;If your AI workflow today involves a lot of tab switching and copy-pasting, that is the cable drawer. It is fine, it works, it is not broken. But there is a different way of doing it now, and the gap between the two is going to keep widening every month as more SaaS companies ship MCP servers for their products.&lt;/p&gt;

&lt;p&gt;You do not have to rush. Nobody is keeping score. But it might be worth at least poking at one server this weekend, just to see.&lt;/p&gt;

&lt;p&gt;That is all I had on this one. If you made it till here, thank you, genuinely. See you in the next one, where I will probably be complaining about something else that broke.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>aitooling</category>
      <category>claude</category>
      <category>security</category>
    </item>
    <item>
      <title>The webhook that worked in Postman and nowhere else</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Mon, 04 May 2026 11:38:41 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/the-webhook-that-worked-in-postman-and-nowhere-else-28o2</link>
      <guid>https://dev.to/vineethnkrishnan/the-webhook-that-worked-in-postman-and-nowhere-else-28o2</guid>
      <description>&lt;h1&gt;
  
  
  The webhook that worked in Postman and nowhere else
&lt;/h1&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%2Fvineethnk.in%2Fblog%2Fthe-webhook-that-worked-in-postman-and-nowhere-else-hero.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%2Fvineethnk.in%2Fblog%2Fthe-webhook-that-worked-in-postman-and-nowhere-else-hero.png" alt="Two identical office doorways at the end of a corridor, one opens into a brightly lit room, the other into a dim corridor that dead-ends. Flat illustration, soft colors, modern editorial style."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: an app I work on was firing webhooks at a third-party device API. The receiver kept returning 401. Postman, with the same payload, got 200 every time. The cause was not signing logic, not auth, not network. The app had two completely different bootstrap paths, the secret-loading config was wired into only one of them, and a silent-skip guard quietly hid the real failure under a misleading 401.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So there I was, staring at a wall of 401 responses in the logs. The app was firing webhooks at a third-party device API every time something on our side changed state. Every single one was bouncing back as "unauthorized".&lt;/p&gt;

&lt;p&gt;Fine, must be the signature. I copied the raw request body straight out of the logs, dropped it into Postman, signed it the same way the app does, and fired it at the same URL. &lt;strong&gt;200 OK&lt;/strong&gt;. First try.&lt;/p&gt;

&lt;p&gt;So Postman was happy. The app was not. Same payload, same URL, same headers (so I thought), and yet only one of them was getting through.&lt;/p&gt;

&lt;p&gt;If you have ever been in this situation, you know the feeling. There is no Stack Overflow post for "works in Postman, fails from my own app". You have to walk yourself through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, rule out the obvious stuff
&lt;/h2&gt;

&lt;p&gt;I went through the standard checklist before doing anything clever.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Same URL? Yes, copy-pasted from the same config.&lt;/li&gt;
&lt;li&gt;Same body? Yes, byte for byte.&lt;/li&gt;
&lt;li&gt;Same auth header? Yes, same shared secret loaded from the same env file.&lt;/li&gt;
&lt;li&gt;Time skew? The timestamp inside the signature was within a few seconds of the receiver's clock.&lt;/li&gt;
&lt;li&gt;IP whitelist? No, the receiver does not even check the source IP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So on paper the two requests were the same. The receiver clearly disagreed. Which meant I had to see what the app was actually putting on the wire, not what I thought it was putting on the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diff that made the cause obvious
&lt;/h2&gt;

&lt;p&gt;I added a logger that dumped the full outgoing HTTP request right before the dispatch: method, URL, every header, body. Then I triggered an event from the app and let it fire. Side by side with the Postman request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Postman                              App
-----------------------------------  -----------------------------------
POST /webhook                        POST /webhook
Content-Type: application/json       Content-Type: application/json
X-Signature: sha256=a3f4...e991      X-Signature:
User-Agent: Postman                  User-Agent: GuzzleHttp/...
{"event":"door.unlocked",...}        {"event":"door.unlocked",...}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the second-to-last line on the right. The app &lt;em&gt;was&lt;/em&gt; sending the &lt;code&gt;X-Signature&lt;/code&gt; header. The value was just an empty string. Postman had a signature, the app had nothing.&lt;/p&gt;

&lt;p&gt;That was a relief in a small, sad way. At least there was something to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is the signature empty?
&lt;/h2&gt;

&lt;p&gt;Easy enough to check. The dispatcher looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function dispatch(event, payload):
    secret = config.get("device_api.signing_secret")
    if secret is empty:
        // skip signing, send anyway
        send(payload, headers={})
        return
    signature = hmac_sha256(secret, payload)
    send(payload, headers={"X-Signature": signature})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things wrong here, but bear with me.&lt;/p&gt;

&lt;p&gt;I dropped a log line on the &lt;code&gt;secret = ...&lt;/code&gt; line. The value came back &lt;code&gt;null&lt;/code&gt;. At runtime, in the queue worker's process, the signing secret was just not there.&lt;/p&gt;

&lt;p&gt;But the same config file. The same env. The same code reading from the same key. Why was it empty in the worker and full in the HTTP layer?&lt;/p&gt;

&lt;p&gt;Has this happened to you also, where two parts of the same app behave like they live in different universes? Welcome to bootstrap drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two doors that look the same from the outside
&lt;/h2&gt;

&lt;p&gt;The app, like a lot of older codebases, has more than one entrypoint. There is the HTTP entrypoint that serves the website, the API endpoints, anything that comes in over a request. And separately there is a queue worker entrypoint that handles background jobs: sending mails, replicating data, dispatching webhooks (yes, &lt;em&gt;that&lt;/em&gt; webhook).&lt;/p&gt;

&lt;p&gt;Both entrypoints share most of the codebase. They both load the same config files. They both connect to the same database. From the file tree, they look identical.&lt;/p&gt;

&lt;p&gt;But they boot through different paths. The HTTP entrypoint has its own bootstrap routine. The queue worker has its own. And somewhere along the way, the config that loaded the third-party device API secret had been added only to the HTTP entrypoint's bootstrap.&lt;/p&gt;

&lt;p&gt;When a request came in over HTTP, the bootstrap ran, the secret got loaded, the dispatcher had what it needed. Tested manually with Postman replay against the HTTP entrypoint? Worked, because Postman was hitting the side that had the config.&lt;/p&gt;

&lt;p&gt;But the actual production trigger was a queue job. The job ran inside the queue worker process, which booted through the &lt;em&gt;other&lt;/em&gt; path, which never loaded that config. So &lt;code&gt;config.get("device_api.signing_secret")&lt;/code&gt; came back null. Every single time.&lt;/p&gt;

&lt;p&gt;The two entrypoints had drifted apart. Whoever added the config load had put it where they could see it being needed (the HTTP layer, where the test was easy), and nobody noticed that the queue worker was also calling the same dispatcher.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug: the silent-skip guard
&lt;/h2&gt;

&lt;p&gt;Look at the dispatcher again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if secret is empty:
    // skip signing, send anyway
    send(payload, headers={})
    return
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comment is the second crime scene.&lt;/p&gt;

&lt;p&gt;When the secret was missing, instead of throwing an error, the dispatcher quietly stripped the signature header and sent the request anyway. So the receiver, who is doing what every signed-webhook receiver does, saw an unsigned request and answered 401.&lt;/p&gt;

&lt;p&gt;From the outside, what we saw was: webhooks fail with 401. The obvious assumption is that the signature is wrong. We spent a good while looking at HMAC code, hashing algorithms, payload encoding, header casing. All of that was fine. The bug was four layers up the stack from where the symptom was showing.&lt;/p&gt;

&lt;p&gt;If the dispatcher had just thrown a loud &lt;code&gt;MissingSecretError: device_api.signing_secret is null&lt;/code&gt;, the cause would have shown up the very first time a webhook tried to fire. Instead it whispered "no signature, oh well", and the receiver did the polite thing and rejected it. Two pieces of code, each individually being defensive, together producing a misleading symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the meta-fix
&lt;/h2&gt;

&lt;p&gt;The local fix was a one-liner. Move the config load into the shared bootstrap that runs for every entrypoint. Now every process that boots, whether HTTP, worker, CLI, or cron, has the secret loaded by the time anything else runs.&lt;/p&gt;

&lt;p&gt;The meta-fix was the silent-skip guard. I changed it to throw if the secret is missing in any non-test environment. If somebody, some day, manages to start a worker process without that config loaded, I want it to crash on the first webhook attempt with a useful error, not soldier on producing 401s for hours.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if secret is empty:
    if env != "test":
        throw MissingSigningSecret("device_api.signing_secret")
    // tests can opt in to unsigned mode
    send(payload, headers={})
    return
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Took maybe ten minutes to write. The bug had been confusing me for a good chunk of the day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two lessons I am writing on the wall
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cross-cutting config belongs in the shared bootstrap, not in the entrypoint-specific one.&lt;/strong&gt; If a piece of config is needed by code that runs in more than one process type, the only safe place to load it is somewhere all of those processes pass through. Not the HTTP bootstrap. Not the worker bootstrap. The one underneath both. Otherwise you are building two apps that pretend to be the same app, and they will eventually disagree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent-skip guards turn loud failures into quiet ones.&lt;/strong&gt; If a value being missing is going to make the next operation meaningless, do not paper over it. Throw. The sound of a real error in a dev environment is so much cheaper than the silence of a wrong-but-running production. There are exceptions, where degrading gracefully is genuinely the right answer. But the default should be loud, and "quiet on missing config" is almost never the right answer.&lt;/p&gt;

&lt;p&gt;If you have hit this kind of bootstrap drift in your own apps, I would love to hear how you spotted it. Mine was pure luck. The request logger I added was actually for an unrelated thing, and I noticed the empty header by accident. Without that I might still be reading HMAC source somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Looking back, this whole thing was less about webhooks and more about how easy it is for two parts of the same app to grow apart without anyone noticing. The codebase looks like one app from the file tree. It runs as two different apps from the operating system's point of view. That gap is where bugs like this live.&lt;/p&gt;

&lt;p&gt;If your app has more than one entrypoint, today is a good day to grep for &lt;code&gt;bootstrap&lt;/code&gt; and check whether all of them are setting up the same world.&lt;/p&gt;

&lt;p&gt;That is pretty much it from my side today. Let me know what you think, or if you have been through something similar, those stories are always the best ones. See you soon in the next blog.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>webhooks</category>
      <category>bootstrap</category>
      <category>queueworkers</category>
    </item>
  </channel>
</rss>
