<?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.us-east-2.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>My Portfolio Has More CI Than My Day Job</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Tue, 07 Jul 2026 12:22:24 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/my-portfolio-has-more-ci-than-my-day-job-1m78</link>
      <guid>https://dev.to/vineethnkrishnan/my-portfolio-has-more-ci-than-my-day-job-1m78</guid>
      <description>&lt;h1&gt;
  
  
  My Portfolio Has More CI Than My Day Job
&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%2Fmy-portfolio-has-more-ci-than-my-day-job-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%2Fmy-portfolio-has-more-ci-than-my-day-job-hero.png" alt="A South Asian developer in a black t-shirt sitting alone at a glass desk in a dramatic, cinematic office, looking slightly overwhelmed at a large monitor showing a green CI pipeline, floating holographic badges reading version 0.0.54 and a CHANGELOG hovering around him, warm rim lighting, shallow depth of field." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; My personal site is on release 0.0.54. It has a CHANGELOG, a commit linter that rejects my own commits, three security scanners, browser tests, and visual regression checks. Nobody uses any of this except me. I regret none of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The other day I sat down to fix a typo on my own website. One word. I wrote the commit, pushed it, and my own CI slapped it back in my face because the commit message did not follow Conventional Commits.&lt;/p&gt;

&lt;p&gt;Let me sit with that for a second.&lt;/p&gt;

&lt;p&gt;A machine I set up, to guard a website only I edit, refused a one-word typo fix because I forgot to put a &lt;code&gt;fix(blog):&lt;/code&gt; in front of my message. And the funny part? I did not even feel annoyed. I felt a little proud. That is the exact moment I realised my portfolio has quietly become more engineered than most of the actual products I get paid to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  How did a blog end up with a version number
&lt;/h2&gt;

&lt;p&gt;Let me show you the receipt first.&lt;/p&gt;

&lt;p&gt;My site is at version &lt;strong&gt;0.0.54&lt;/strong&gt;. That is not a typo and it is not a joke. There is a real &lt;code&gt;package.json&lt;/code&gt; with a real version field, and a tool called release-please that bumps it every single time I merge something. Each release cuts a tag, writes a GitHub release, and appends to a &lt;code&gt;CHANGELOG.md&lt;/code&gt; that reads like a serious piece of software.&lt;/p&gt;

&lt;p&gt;Here is an actual entry from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## [0.0.54](https://.../compare/v0.0.53...v0.0.54) (2026-06-14)&lt;/span&gt;

&lt;span class="gu"&gt;### Features&lt;/span&gt;
&lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="gs"&gt;**blog:**&lt;/span&gt; moving a homelab from .de to .in
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A changelog. With compare links. Documenting the breaking changes to... a page about my home server. Fifty-three of these releases sit in my git history, each one a tiny ceremony for shipping a blog post nobody was waiting on.&lt;/p&gt;

&lt;p&gt;The thing is, a changelog exists so users know what changed between versions they might be running. My "users" are me, and the version they are running is whatever loaded when they opened the tab. There is exactly one deployment and it is always the latest one. The whole concept does not apply. I built it anyway, and honestly it is kind of nice to scroll through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The commit police live in my repo now
&lt;/h2&gt;

&lt;p&gt;So back to that typo. The reason my commit got rejected is a workflow called commitlint. Every message I write gets checked against a set of rules. Right type. Right scope. Lowercase subject. Under a certain length. No trailing period.&lt;/p&gt;

&lt;p&gt;If I fumble any of it, the whole thing goes red and I have to go back and fix my own sentence before my own website will accept it.&lt;/p&gt;

&lt;p&gt;On a team, this makes complete sense. You have ten people writing commits and you want the history to read consistently so the changelog generates cleanly. On a repo where the only author is me, arguing with myself at two in the morning about whether a change is a &lt;code&gt;fix&lt;/code&gt; or a &lt;code&gt;chore&lt;/code&gt;, it is pure theatre. Good theatre though. I have written cleaner commit messages on my blog than on things that pay my rent, and that is a slightly embarrassing sentence to type out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests. For a website. That only I touch.
&lt;/h2&gt;

&lt;p&gt;Now we get to the part where I really lost the plot.&lt;/p&gt;

&lt;p&gt;I write Playwright tests for my portfolio. Real browser tests, spinning up a headless Chrome, clicking through the site to make sure it works. There is one for navigation. One for the search modal. One for the blog pages, one for the sections on the landing page, one for the theme switcher.&lt;/p&gt;

&lt;p&gt;And then, because apparently that was not enough, there is visual regression. My CI takes screenshots of the site, compares them pixel by pixel against saved snapshots, and if anything shifts it flags it and commits the new snapshots back. So if I nudge a button three pixels to the left, a robot notices and files the paperwork.&lt;/p&gt;

&lt;p&gt;Who is this protecting? Me. From me. The only person who can break this site is the same person writing the tests to catch himself breaking it. It is the software equivalent of leaving myself angry sticky notes.&lt;/p&gt;

&lt;p&gt;Have you ever built a safety net so elaborate that the net became the most impressive thing in the building? Because that is roughly where I landed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the over-engineering buffet
&lt;/h2&gt;

&lt;p&gt;While I was in there, I did not stop at tests. The site also has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;command palette search&lt;/strong&gt;, the little &lt;code&gt;Cmd+K&lt;/code&gt; modal that power tools have, so I can fuzzy-search my own blog posts with a keyboard shortcut. I have around forty posts. I know all of them. I still built the search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;giscus comments&lt;/strong&gt;, wired through GitHub Discussions, so readers can comment. The comment count is, let us say, a very honest number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-posting&lt;/strong&gt;, an automated job that pushes new posts out to dev.to on its own, with a cache so it does not double-post.&lt;/li&gt;
&lt;li&gt;Three separate &lt;strong&gt;security scanners&lt;/strong&gt; running on every change. CodeQL for code analysis, a dependency review, and Trivy scanning the filesystem for known vulnerabilities. On a static site. That has no login, no database, no user input, and no server doing anything at runtime. The attack surface is roughly the size of a postcard and I have three guards watching it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reading that list back, it sounds like I am describing a fintech backend, not a place where I complain about Docker.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why do it, really
&lt;/h2&gt;

&lt;p&gt;Here is the honest turn, and it is not the one you might expect.&lt;/p&gt;

&lt;p&gt;None of this was necessary. I want to be very clear about that. A personal site needs a build step and a place to host it, full stop. Everything else I piled on top is decoration.&lt;/p&gt;

&lt;p&gt;But every single piece of that decoration taught me something I then used at work. Setting up release-please on a low-stakes repo meant that when a real project needed automated releases, I already knew the sharp edges. The Playwright visual regression I fought with here is the same setup I later reached for on a production app where a broken layout actually costs money. My personal site turned into the sandbox where I get to make all the mistakes for free, with nobody paged and no customer affected.&lt;/p&gt;

&lt;p&gt;The day job gives you production systems but not always the freedom to experiment on them. You cannot casually try a new CI pattern on the thing that pays real salaries. So the experiments have to live somewhere, and for me that somewhere is a blog with a version number.&lt;/p&gt;

&lt;p&gt;Is it overkill? Completely. Would I rip any of it out? Not a chance. The overkill is the point.&lt;/p&gt;

&lt;p&gt;So that is the confession. My portfolio has a CHANGELOG nobody reads, tests nobody triggers, and security scans for an attack surface that does not exist, and I would set every bit of it up again tomorrow. If you have a personal project quietly carrying more engineering than it could ever need, you already know it is not really about the project.&lt;/p&gt;

&lt;p&gt;Not going to pretend this was a perfectly rational way to spend my evenings. But if even one part of it nudges you to treat your own side project as the safe place to try the scary stuff, then it was worth writing down. See you in the next one.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>overengineering</category>
      <category>astro</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>I taught WeSpend to read GPay screenshots. OCR fought back.</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sat, 04 Jul 2026 14:00:16 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/i-taught-wespend-to-read-gpay-screenshots-ocr-fought-back-39i</link>
      <guid>https://dev.to/vineethnkrishnan/i-taught-wespend-to-read-gpay-screenshots-ocr-fought-back-39i</guid>
      <description>&lt;h1&gt;
  
  
  I taught WeSpend to read GPay screenshots. OCR fought back.
&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%2Fteaching-wespend-to-read-gpay-screenshots-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%2Fteaching-wespend-to-read-gpay-screenshots-hero.png" alt="A smartphone on a warm wooden desk showing a mobile payment success screen with a rupee amount, a teal scan line sweeping across it and a few digits dissolving into particles as if being misread, a second phone beside it showing an expense-tracker app. Cinematic realistic photo with moody lighting and teal accents." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vineethkrishnan.github.io/wespend/" rel="noopener noreferrer"&gt;WeSpend&lt;/a&gt; is a small app I built for my household. One person funds a shared monthly pot, everyone logs what they spend from their own phone, and at the end of the week it works out who owes whom. The whole thing lives or dies on one boring question: how easy is it to add an expense? Because if adding an expense takes ten taps, nobody does it, and then the numbers are a lie.&lt;/p&gt;

&lt;p&gt;So I added what felt like a lazy little shortcut. You pay someone on GPay, you get that green success screen, you share that screenshot straight to WeSpend, and the app reads the amount and fills it in for you. One share, done. On-device OCR, no typing.&lt;/p&gt;

&lt;p&gt;It worked beautifully. For exactly half the screenshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Half the screenshots. The nice round half.
&lt;/h2&gt;

&lt;p&gt;Here is the pattern I did not notice at first. A payment of &lt;code&gt;₹287&lt;/code&gt; came through perfectly, every single time. A payment of &lt;code&gt;₹130.00&lt;/code&gt; came through as zero rupees. Same app, same screen, same OCR. The only difference was those two little zeros after the dot.&lt;/p&gt;

&lt;p&gt;Clean integer amounts, the ones with no paise, sailed through. The moment there was a &lt;code&gt;.00&lt;/code&gt; on the screen, the amount field just quietly filled in &lt;code&gt;0&lt;/code&gt; and sat there looking innocent.&lt;/p&gt;

&lt;p&gt;If you have ever watched an app fill in a number with total confidence and get it completely wrong, you know the exact little sting I felt. It is worse than an error. An error at least admits something went wrong.&lt;/p&gt;

&lt;p&gt;So I did what you do. I started printing out exactly what the OCR was handing me, one screenshot at a time. And that is where it got funny.&lt;/p&gt;

&lt;h2&gt;
  
  
  OCR is not bad at reading. It is bad in very specific, creative ways.
&lt;/h2&gt;

&lt;p&gt;I was using Google's ML Kit for the on-device text recognition. It is genuinely good. But a stylized GPay payment screen is not clean print, and the ways it got things wrong were oddly consistent. Once I saw the actual output, the mystery fell apart.&lt;/p&gt;

&lt;p&gt;Here is the collection I ended up with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It eats the rupee sign.&lt;/strong&gt; The &lt;code&gt;₹&lt;/code&gt; on that success screen is a nice stylized glyph, and OCR would sometimes just drop it entirely. &lt;code&gt;₹130.00&lt;/code&gt; came back as &lt;code&gt;130.00&lt;/code&gt;. Not the end of the world on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It reads zero as the letter O.&lt;/strong&gt; This was the real culprit behind the &lt;code&gt;.00&lt;/code&gt; problem. &lt;code&gt;130.00&lt;/code&gt; came back as &lt;code&gt;130.OO&lt;/code&gt;, with two capital letter O's where the zeros should be. To my parser, &lt;code&gt;130.OO&lt;/code&gt; is not a number at all, so it gave up and left &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It reads the decimal point as a space.&lt;/strong&gt; On some screens the same amount came back as &lt;code&gt;130 00&lt;/code&gt;. Now it looks like two separate numbers, &lt;code&gt;130&lt;/code&gt; and &lt;code&gt;00&lt;/code&gt;, and neither is the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And my favourite, it reads the rupee sign as the number 7.&lt;/strong&gt; This one I did not see coming. On a few screens the &lt;code&gt;₹&lt;/code&gt; was not dropped, it was confidently transcribed as a &lt;code&gt;7&lt;/code&gt;. So &lt;code&gt;₹280.00&lt;/code&gt; came back as &lt;code&gt;7280.00&lt;/code&gt;. That is not a missing rupee, that is a fake two thousand rupees added to my payment. Imagine settling the week off that.&lt;/p&gt;

&lt;p&gt;I sat there looking at &lt;code&gt;130.OO&lt;/code&gt; and &lt;code&gt;7280.00&lt;/code&gt; and honestly laughed. My clean little shortcut had walked straight into the real world.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it, one liar at a time
&lt;/h2&gt;

&lt;p&gt;The temptation here is to write one big clever regex that handles everything. Do not do that. I tried. It becomes unreadable in about twenty minutes and then it eats a phone number and tells you the auto ride cost forty-two lakh.&lt;/p&gt;

&lt;p&gt;What actually worked was treating each specific way OCR lies as its own small, named repair, each one narrow enough that I could write a test for it and trust it.&lt;/p&gt;

&lt;p&gt;The rupee-as-7 one is a good example. If a number starts with &lt;code&gt;7&lt;/code&gt;, and the character just before it is not a digit, and stripping that leading &lt;code&gt;7&lt;/code&gt; still leaves a valid positive amount, I treat it as a currency amount where the &lt;code&gt;7&lt;/code&gt; was really the rupee sign. So &lt;code&gt;7280.00&lt;/code&gt; becomes &lt;code&gt;280.00&lt;/code&gt;. There is one important guard: if the character after that &lt;code&gt;7&lt;/code&gt; is a comma, I leave it completely alone, because &lt;code&gt;7,280.00&lt;/code&gt; is a perfectly real number in Indian grouping and I have no business touching it.&lt;/p&gt;

&lt;p&gt;The mangled cents was the fix that actually shipped the feature. When a line looks like a real amount but the fractional part is unreadable, one or two characters of garbage like &lt;code&gt;OO&lt;/code&gt; or a stray space, I now trust the integer part and just throw the broken fraction away. &lt;code&gt;130.OO&lt;/code&gt; becomes &lt;code&gt;130&lt;/code&gt;. &lt;code&gt;130 00&lt;/code&gt; becomes &lt;code&gt;130&lt;/code&gt;. The screen said one hundred and thirty rupees, and one hundred and thirty rupees is what you get.&lt;/p&gt;

&lt;p&gt;The one rule that keeps all of this safe is a cap I almost forgot: the discarded fraction can only be one or two characters. That tiny limit is doing a lot of quiet work. A reference number, a date, a phone number, a UPI transaction ID, none of those have a short one-or-two-character tail, so none of them get mistaken for an amount with mangled cents. Without that cap, this whole feature would be a slot machine.&lt;/p&gt;

&lt;p&gt;There was one more from earlier that fits the same family. OCR sometimes drops the decimal point but keeps the thousands comma, so &lt;code&gt;1,557.40&lt;/code&gt; arrives as &lt;code&gt;1,55740&lt;/code&gt;. A real integer's last comma group is always exactly three digits, in both Indian and Western grouping. So if that last group is longer than three, I know the final two digits are the dropped paise, and I put the dot back. Same idea every time. Learn one specific way the machine lies, write the narrow repair, cap it so it cannot overreach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half of the problem: which number is even the amount?
&lt;/h2&gt;

&lt;p&gt;I have been talking as if there is one number on the screen. A bank SMS is worse. It has the amount, the available balance, maybe a transaction reference, sometimes a date that reads like a number too.&lt;/p&gt;

&lt;p&gt;So the parser also has to know which figure is the spend. A running balance is never the amount you spent, so anything sitting next to words like &lt;code&gt;avbl&lt;/code&gt;, &lt;code&gt;bal&lt;/code&gt;, or &lt;code&gt;balance&lt;/code&gt; gets ruled out. A word like &lt;code&gt;credited&lt;/code&gt; or &lt;code&gt;refund&lt;/code&gt; flips the whole thing from a spend to a credit. And a genuine debit names the rail it went over, &lt;code&gt;a/c&lt;/code&gt;, &lt;code&gt;upi&lt;/code&gt;, &lt;code&gt;imps&lt;/code&gt;, &lt;code&gt;card&lt;/code&gt;, while a promo message or an OTP never does. None of this is glamorous. All of it is the difference between an expense tracker you trust and one you quietly stop using.&lt;/p&gt;

&lt;p&gt;You do not need me to spell out which of those two an untrusted expense tracker becomes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually took away from this
&lt;/h2&gt;

&lt;p&gt;Every one of these fixes started as a real screenshot that embarrassed me. So every one of them became a test with the actual garbled text pasted in, mangled O's and fake sevens and all. My test file now has a merchant called Loaded Gazebo in it, which is not a real shop, it is just the fake payee I kept reusing while chasing this. That file is the most honest documentation in the whole project, because it is literally a list of the ways the real world broke my assumptions.&lt;/p&gt;

&lt;p&gt;The bigger lesson, if there is one, is that OCR on real screenshots is not a clean input you parse. It is an adversary with a small, learnable set of tricks. You do not beat it with cleverness. You beat it by writing down each trick, one narrow rule at a time, and capping every rule so it can never be too confident. Which, now that I type it out, is basically how you survive anything that lies to you in predictable ways.&lt;/p&gt;

&lt;p&gt;Anyway, WeSpend reads my GPay screenshots now. Round amounts, paise, dropped rupee signs, fake sevens, all of it. Adding an expense is one share again, the way it was always supposed to be, and the household numbers have stopped being a polite fiction.&lt;/p&gt;

&lt;p&gt;Okay, that is enough out of me for today. If your own side project has one of these little features that turned out to be an entire iceberg, I would genuinely love to hear what was hiding under yours. Until the next one, go easy on your OCR, it is trying its best.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ocr</category>
      <category>expensetracker</category>
      <category>wespend</category>
    </item>
    <item>
      <title>vaultctl Has a Browser Extension Now</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Thu, 02 Jul 2026 14:56:55 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/vaultctl-has-a-browser-extension-now-1j5b</link>
      <guid>https://dev.to/vineethnkrishnan/vaultctl-has-a-browser-extension-now-1j5b</guid>
      <description>&lt;h1&gt;
  
  
  vaultctl Has a Browser Extension Now
&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-has-a-browser-extension-now-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-has-a-browser-extension-now-hero.png" alt="A developer working at his laptop in a warm, plant-filled room, with the dark VaultCTL browser extension popup floating beside him. The popup shows a teal shield-with-keyhole logo, an Unlock with Touch ID button, a masked master password field, and an Unlock button. Realistic photo with teal accents." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a long time vaultctl was three things. A single Go binary on my server, a web app, and a CLI. All of them worked. None of them were where I actually needed them, which is the exact moment a login form shows up and I have to go somewhere else to copy a password.&lt;/p&gt;

&lt;p&gt;So now there is a fourth thing. A browser extension. It sits in your toolbar, unlocks with Touch ID, and fills your logins on the page where you are standing. This post is the story of getting there, and I will be honest with you, it took a lot more than I thought.&lt;/p&gt;

&lt;p&gt;This is another post in the series where I walk through my open-source projects. If you want the why-does-this-exist and the zero-knowledge story, that is all in &lt;a href="https://vineethnk.in/blog/building-vaultctl" rel="noopener noreferrer"&gt;building vaultctl&lt;/a&gt;. This one is just about the extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  It started with a lazy question
&lt;/h2&gt;

&lt;p&gt;I was using the web app to grab a password, paste it, then go back. Again. And again. One of those afternoons I just typed out loud into the chat, "do we even have a browser extension?"&lt;/p&gt;

&lt;p&gt;We did not. There was a folder, some scaffolding, a popup that showed nothing useful. That was it.&lt;/p&gt;

&lt;p&gt;That one lazy question turned into the single longest stretch of work in the whole project. Funny how that goes. The features you announce proudly take a week. The feature that is "just autofill, how hard can it be" takes over your life.&lt;/p&gt;

&lt;h2&gt;
  
  
  Autofill is not a feature, it is the entire job
&lt;/h2&gt;

&lt;p&gt;Here is the thing nobody tells you. A password manager extension is maybe ten percent vault and ninety percent fighting with the web.&lt;/p&gt;

&lt;p&gt;Every login page is built differently. Some put the username and password on one screen. Some show you the email first, then the password on a second screen after a redirect. Some render the form late with JavaScript, so when your extension looks for fields on page load, there is nothing there yet. Some have a fake password field for a 2FA code that is not actually the password at all.&lt;/p&gt;

&lt;p&gt;I hit every single one of these. In order. Painfully.&lt;/p&gt;

&lt;p&gt;The multi-step logins were the first wall. You type your email, the page moves to the password step, and by then the extension has forgotten which email you were even using, so it saves a password with no username attached. Useless. I had to make it remember the email across that jump.&lt;/p&gt;

&lt;p&gt;Then the late-rendering forms. The extension would scan the page, find nothing, and give up, all before the actual login form had finished loading. So I added a delay. Then the delay caused a new problem, because if you had already started typing, the autofill would rudely stomp on your input. So then I had to guard the delayed fill against your own typing. One fix, one new bug, the usual dance.&lt;/p&gt;

&lt;p&gt;The save toast was its own little saga. You log in, the extension offers to save the password, and then the site redirects you to your dashboard and the toast vanishes before you can click it. Gone. I lost count of how many times I logged in just to watch that toast disappear. Keeping it alive across a redirect, even a cross-host or single-page-app redirect, took way more attempts than I want to admit.&lt;/p&gt;

&lt;p&gt;And the field picking. The extension kept offering to fill the 2FA code box because it looked like a password field. So I taught it to pick the real username field and not the verification-code field. Small thing. Took ages to get right.&lt;/p&gt;

&lt;p&gt;If you have ever built one of these, none of this is news to you, and you have got the scars to match.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Google-style picker
&lt;/h2&gt;

&lt;p&gt;Somewhere in the middle of all this, I stopped trying to be clever with inline filling and just copied the pattern everyone already understands. A little icon inside the field. You click it, a small picker drops down showing the matching login with the site favicon and the username, password masked. You pick, it fills.&lt;/p&gt;

&lt;p&gt;Sounds simple. The fiddly part was making it behave. Keep the picker open when the field is focused. Suppress the browser's own native dropdown so you are not fighting two popups at once. Scope the suggestions to the exact site so a random login does not show up on the wrong page. Bold the username so you can actually read it at a glance.&lt;/p&gt;

&lt;p&gt;None of these are hard problems on their own. Together they are a hundred tiny papercuts, and the difference between an extension that feels nice and one that feels broken is whether you bothered to fix all hundred.&lt;/p&gt;

&lt;p&gt;The stack under all this, if you care, is &lt;a href="https://wxt.dev/" rel="noopener noreferrer"&gt;WXT&lt;/a&gt; for the extension framework, React 19 and Tailwind 4 for the popup, zustand for state, zod for validation, hash-wasm for the crypto bits, lucide-react for icons, and i18next so the whole thing speaks English and German. Manifest V3, because Chrome gives you no choice anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that made everything slower and I would do it again
&lt;/h2&gt;

&lt;p&gt;vaultctl is a credential manager. The whole reason it exists is so you do not have to trust some other party with your secrets. So I made one rule early and stuck to it. No pulling in random third-party services for the sensitive parts. If a piece is missing, we build our own small version of it.&lt;/p&gt;

&lt;p&gt;This rule cost me time. It was worth every minute.&lt;/p&gt;

&lt;p&gt;Two examples. First, the QR code. When you set up your account you get a recovery kit, and that needs a real, scannable QR. The first version I had was, in the kindest words, a deterministic visual fingerprint. It looked like a QR. It was not a QR. Nothing could scan it. For a production credential manager, that is not a "ship it and fix later" situation. So instead of reaching for some QR library, I wrote a proper QR generator inside the project. Real encoding, real error correction, actually scannable.&lt;/p&gt;

&lt;p&gt;Second, attachments. I wanted to let you attach files to a vault item, securely. The obvious move is to bolt on MinIO or SeaweedFS or some object store. But that is a whole extra service to run, trust, and secure, for a tool whose entire pitch is "do not trust extra parties". So I built a small object storage module right into the binary. One filesystem-backed blob store, encrypted like everything else. No new service, no new trust boundary.&lt;/p&gt;

&lt;p&gt;Is my QR generator as battle-tested as a popular library? No. But it is small, I can read all of it, and nothing about my recovery kit leaves the boundary I control. For a vault, that trade is the right one every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small things that ate whole evenings
&lt;/h2&gt;

&lt;p&gt;The big features get the commits with nice names. The small stuff is where the time actually goes.&lt;/p&gt;

&lt;p&gt;The bottom tab bar in the popup was not fixed in place. So to switch between the vault, the generator, and settings, you had to scroll all the way to the very bottom to even see the tabs. I used my own extension for two minutes and wanted to throw my laptop. Pinning the tab bar to the bottom was a five-minute fix that I should have done on day one.&lt;/p&gt;

&lt;p&gt;Copy was half broken. You could copy the username fine. Copy the password, nothing happened. A credential manager where you cannot copy the credential. Beautiful.&lt;/p&gt;

&lt;p&gt;And then, the one that made me laugh at myself. I went through the extension and found em-dashes sitting in some of the alert and notification text. If you have read anything else on this blog you know exactly how I feel about em-dashes. My own tool was using them. In my own product. I hunted them all down and replaced them with honest little hyphens. Some battles are personal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TOTP rabbit hole
&lt;/h2&gt;

&lt;p&gt;This one I have to be honest about, because I confused myself properly.&lt;/p&gt;

&lt;p&gt;vaultctl can store 2FA. The extension can show you a live TOTP code and fill it in for your logins. Good feature. But while building it I tied myself in a knot over what TOTP even meant in this context.&lt;/p&gt;

&lt;p&gt;See, the recovery kit has its own TOTP, for unlocking your vault. And separately, your saved logins can each carry their own 2FA secret, for the sites you log into. Same letters, two completely different jobs. For a while I genuinely could not tell you which one I was working on, and I kept asking myself out loud, do we even save TOTP, and if we generate the code then where is the secret coming from, and is this the vault's 2FA or the website's.&lt;/p&gt;

&lt;p&gt;The answer, once I slowed down. We store the 2FA secret for your target logins, encrypted like everything else, and generate the code on the fly. The vault's own TOTP is a separate thing. Once I drew that line clearly in my head, the feature was easy. The confusion was the hard part, not the code.&lt;/p&gt;

&lt;p&gt;There was also a related bug worth mentioning, since it is a nice example of doing too much. The extension was showing a fill suggestion on every single OTP input box on a page, even though we do not store one-time codes. Annoying little emblem popping up everywhere. Had to de-duplicate that so it only shows where it makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, what is in it now
&lt;/h2&gt;

&lt;p&gt;Quite a lot, actually. Touch ID unlock. Inline autofill with the picker. Save and update prompts that survive redirects. A multi-vault switcher with cross-vault filling. Capture and fill for credit cards and identity forms, not just logins. Live TOTP codes. A password generator with a memorable-passphrase mode. A password checkup that warns you about weak or compromised passwords. Per-site "never save" if a site annoys you. English and German throughout.&lt;/p&gt;

&lt;p&gt;None of it is glamorous. All of it is the kind of thing you only notice when it is missing.&lt;/p&gt;

&lt;p&gt;If your password manager has ever filled the wrong field, or eaten your save prompt on a redirect, or shrugged at a two-step login, I hope this gives you a little sympathy for whoever built it. I certainly have more sympathy now than I did before.&lt;/p&gt;

&lt;p&gt;vaultctl is open source over at &lt;a href="https://github.com/vineethkrishnan/vaultctl" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/vaultctl&lt;/a&gt;, extension folder and all, if you want to see how the sausage is made.&lt;/p&gt;

&lt;p&gt;That is pretty much it from my side today. If you have been through the same autofill pain, or you have a cleaner way of handling these multi-step login forms, I genuinely want to hear it. Those stories are always the best ones. See you soon in the next blog.&lt;/p&gt;

</description>
      <category>browserextension</category>
      <category>wxt</category>
      <category>autofill</category>
      <category>passwordmanager</category>
    </item>
    <item>
      <title>moving a homelab from .de to .in without breaking the tunnel</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sun, 14 Jun 2026 13:31:51 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/moving-a-homelab-from-de-to-in-without-breaking-the-tunnel-11hh</link>
      <guid>https://dev.to/vineethnkrishnan/moving-a-homelab-from-de-to-in-without-breaking-the-tunnel-11hh</guid>
      <description>&lt;h1&gt;
  
  
  moving a homelab from .de to .in without breaking the 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%2Fvineethnk.in%2Fblog%2Fmoving-a-homelab-from-de-to-in-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%2Fmoving-a-homelab-from-de-to-in-hero.png" alt="A macOS terminal window showing a Cloudflare tunnel ingress config, with both the old .de and new .in hostnames pointing at the same local services during the migration."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I run a small homelab on a Mac mini, fronted by a single Cloudflare tunnel, with Tailscale guarding everything internal. I moved the public side from &lt;code&gt;vinelabs.de&lt;/code&gt; to &lt;code&gt;vinelab.in&lt;/code&gt;, because I operate out of India and the &lt;code&gt;.de&lt;/code&gt; belonged to a different chapter. It was the right call, and I am not second guessing it. The move itself was mostly painless once I stopped treating it as one big switch. The tunnel config turned out to be only half the job, DNS is the other half, Vaultwarden has a sneaky domain setting that bites, and I nearly corrupted my status page database by being too clever with SQLite. I am keeping the &lt;code&gt;.de&lt;/code&gt; though, for German related work, once DENIC clears the paperwork. Here is the whole thing, mistakes included.&lt;/p&gt;

&lt;h2&gt;
  
  
  why i even did this
&lt;/h2&gt;

&lt;p&gt;Let me start with the why, because the how only makes sense after that.&lt;/p&gt;

&lt;p&gt;For a good while my homelab lived on &lt;code&gt;vinelabs.de&lt;/code&gt;. It was fine. Everything worked. The tunnel was up, the services were reachable, nobody was complaining (mostly because the only user is me). So why touch a working thing?&lt;/p&gt;

&lt;p&gt;If you have not seen the setup before, it is nothing exotic. One Mac mini at home runs the whole thing through Docker. A single Cloudflare tunnel fronts the handful of services I actually want reachable from the public internet: a landing page behind Caddy, my Vaultwarden, a small password tool I built called VaultCTL, an Uptime Kuma status page, and a webhook endpoint for some ticket automation. Everything else, n8n and ntfy and the rest, stays inside my Tailscale tailnet where it belongs and never touches a public name at all. So when I say I moved the domain, I really mean that public edge, the five or so hostnames the tunnel answers for. Nothing internal had to change, which is half the reason the move stayed calm.&lt;/p&gt;

&lt;p&gt;A few reasons piled up. The first one is just identity. I am in India. I work from India. My whole setup runs out of a Mac mini sitting in my home in India. And every time I typed &lt;code&gt;vinelabs.de&lt;/code&gt; I felt this tiny mismatch, like wearing someone else's jacket that happens to fit. The &lt;code&gt;.de&lt;/code&gt; was from an earlier phase. That phase is not over, but I wanted my root identity to match where I actually am, so this was the right time to make the switch.&lt;/p&gt;

&lt;p&gt;The second reason was a cleaner brand. &lt;code&gt;vinelab.in&lt;/code&gt; is shorter, it reads better, and it actually says where I am.&lt;/p&gt;

&lt;p&gt;And the third reason was the practical nudge. Holding a &lt;code&gt;.de&lt;/code&gt; now means dealing with DENIC, the registry that runs the &lt;code&gt;.de&lt;/code&gt; zone, and proving a proper holder identity that lines up with the rules for who can own one. Sorting that out from India, for a domain that no longer matched what I was using it for, was the push I needed. A &lt;code&gt;.in&lt;/code&gt; I can hold cleanly, from right here, no awkward paperwork about why someone in India is fronting a German domain.&lt;/p&gt;

&lt;p&gt;So I switched the homelab to &lt;code&gt;vinelab.in&lt;/code&gt;, and looking back it was clearly the right move. But I did not kill the old one, and this is the part I actually like. &lt;code&gt;vinelabs.de&lt;/code&gt; is still mine. Once I hear back from DENIC and the holder side is sorted, the plan is to give it a proper second life: German related work and the odd hobby project that genuinely belongs on a &lt;code&gt;.de&lt;/code&gt;. It is not a tombstone. It is just moving to a shelf where it fits better. The homelab gets the &lt;code&gt;.in&lt;/code&gt; it should have had from day one, and the &lt;code&gt;.de&lt;/code&gt; gets to be the thing it was always more suited for.&lt;/p&gt;

&lt;h2&gt;
  
  
  the one rule that saved me: keep both live
&lt;/h2&gt;

&lt;p&gt;Here is the single decision that made this whole thing low stress.&lt;/p&gt;

&lt;p&gt;Do not flip from old to new in one go. Run both at the same time for a bit.&lt;/p&gt;

&lt;p&gt;My setup is one Cloudflare tunnel pointing at a bunch of local services. The routing lives in a config file, and the trick was simply to add the new hostnames next to the old ones, not replace them. Same service, two doors.&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="c1"&gt;# both domains point at the same local services during the move&lt;/span&gt;
&lt;span class="c1"&gt;# the .de ones come out later, once i trust the .in ones&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;home.vinelab.in&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;locker.vinelabs.de&lt;/span&gt;   &lt;span class="c1"&gt;# vaultwarden&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:8222&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;locker.vinelab.in&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:8222&lt;/span&gt;

  &lt;span class="c1"&gt;# ...same pattern for vault, status, agents&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;span class="c1"&gt;# catch-all, required&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now both &lt;code&gt;home.vinelabs.de&lt;/code&gt; and &lt;code&gt;home.vinelab.in&lt;/code&gt; hit the same landing page. Nothing breaks the moment I add the new names, and I get to test the new domain properly before trusting it with anything.&lt;/p&gt;

&lt;p&gt;This is the part I would tell anyone doing a domain move. The cutover is not a single scary switch. It is a slow handover where both sides work, and then one day you quietly remove the old side.&lt;/p&gt;

&lt;h2&gt;
  
  
  the tunnel config is only half the story
&lt;/h2&gt;

&lt;p&gt;This one got me for a second, so let me save you the same confusion.&lt;/p&gt;

&lt;p&gt;Adding a hostname to the tunnel config does not make it resolve. The ingress rules tell the tunnel "if traffic for this hostname shows up, send it here". But traffic only shows up if DNS actually points the name at the tunnel in the first place. Two separate things. The config is necessary, not sufficient.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;vinelab.in&lt;/code&gt; had to become a real zone in Cloudflare, with the registrar pointing at Cloudflare's nameservers, and then a DNS record per hostname routing to the tunnel. For a tunnel these are proxied CNAME records, the orange-cloud kind.&lt;/p&gt;

&lt;p&gt;And here is the small gotcha that made me doubt myself. When I went to check the new records with &lt;code&gt;dig&lt;/code&gt;, I did not see a CNAME pointing at the tunnel at all. I saw Cloudflare's own IP addresses instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;home&lt;/span&gt;.&lt;span class="n"&gt;vinelab&lt;/span&gt;.&lt;span class="n"&gt;in&lt;/span&gt;    &lt;span class="n"&gt;A&lt;/span&gt;    &lt;span class="m"&gt;104&lt;/span&gt;.&lt;span class="m"&gt;21&lt;/span&gt;.&lt;span class="m"&gt;55&lt;/span&gt;.&lt;span class="m"&gt;148&lt;/span&gt;
&lt;span class="n"&gt;home&lt;/span&gt;.&lt;span class="n"&gt;vinelab&lt;/span&gt;.&lt;span class="n"&gt;in&lt;/span&gt;    &lt;span class="n"&gt;A&lt;/span&gt;    &lt;span class="m"&gt;172&lt;/span&gt;.&lt;span class="m"&gt;67&lt;/span&gt;.&lt;span class="m"&gt;149&lt;/span&gt;.&lt;span class="m"&gt;38&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a moment I thought the routing was broken. It was not. When a record is proxied, Cloudflare hides the real CNAME and hands you its anycast IPs instead, because the whole point of proxying is that the world talks to Cloudflare and not to your origin. So an empty CNAME and a couple of &lt;code&gt;104.x&lt;/code&gt; / &lt;code&gt;172.x&lt;/code&gt; addresses is exactly what a working tunnel record looks like. The real test was just hitting the URL and seeing the right service answer, which it did.&lt;/p&gt;

&lt;p&gt;Has this confused you before too? You go looking for proof in &lt;code&gt;dig&lt;/code&gt; and the proxy quietly rewrites the answer on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  the vaultwarden gotcha nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Most of my services did not care about the domain. A landing page does not know its own name. A status page does not know its own name. You point the new hostname at the same port and you are done.&lt;/p&gt;

&lt;p&gt;Vaultwarden is not like that.&lt;/p&gt;

&lt;p&gt;Vaultwarden has a &lt;code&gt;DOMAIN&lt;/code&gt; setting baked into its config, and it is not cosmetic. That value is the origin used for WebAuthn, which is the thing behind passkeys and hardware security keys. If you change the domain, the old passkeys stop validating, because a passkey is tied to the exact origin it was registered against. The browser will simply refuse, and it is right to.&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="c1"&gt;# before&lt;/span&gt;
&lt;span class="na"&gt;DOMAIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://locker.vinelabs.de&lt;/span&gt;
&lt;span class="c1"&gt;# after, then recreate the container so it actually picks this up&lt;/span&gt;
&lt;span class="na"&gt;DOMAIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://locker.vinelab.in&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the move here is two steps, not one. Change the value, then recreate the container. And go in knowing that any passkey you registered on the old origin needs to be added again on the new one. Master password and your normal two-factor are fine. Only the passkey side cares. I would rather you read that here than discover it while staring at a login screen that keeps saying no.&lt;/p&gt;

&lt;p&gt;One update since I wrote this. I have since deprecated Vaultwarden and moved to VaultCTL, the small password tool I mentioned earlier that I built myself, mostly because I wanted a tighter security story than I was getting before. VaultCTL is what I actually use now. Vaultwarden is parked for the moment, still up but not the thing I reach for, and it gets pulled out of the homelab for good a bit later. So treat this whole Vaultwarden section as the history of the move rather than how my setup looks today. The &lt;code&gt;DOMAIN&lt;/code&gt; lesson still holds for anyone running Vaultwarden through a tunnel, which is why I am leaving it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  the status monitor that lied to me
&lt;/h2&gt;

&lt;p&gt;This is my favourite kind of bug. The thing that is broken is not actually broken.&lt;/p&gt;

&lt;p&gt;I run Uptime Kuma to watch my services, and two of those monitors track my Restic backups. They are push monitors, which work backwards from a normal check. Instead of Kuma poking the service, the backup script pings Kuma after it finishes. No ping inside the window, Kuma marks it down.&lt;/p&gt;

&lt;p&gt;After the move, my backup health went red. My first thought was the obvious one, the backups are failing. They were not. The backups were running perfectly fine.&lt;/p&gt;

&lt;p&gt;The problem was the ping address. The backup scripts were still pinging &lt;code&gt;status.vinelabs.de&lt;/code&gt;, and during the move that old hostname had lost its DNS. So the script would finish the backup, try to phone home to a domain that no longer resolved, fail silently on that one line, and Kuma would sit there hearing nothing and assume the worst.&lt;/p&gt;

&lt;p&gt;The fix was nicer than just swapping the domain. These scripts run on the same machine as Kuma. They have no business going out to the public internet and back just to say hello to a service sitting right next to them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# was: depends on public dns + the tunnel just to report health&lt;/span&gt;
https://status.vinelabs.de/api/push/xxxx

&lt;span class="gh"&gt;# now: same box talking to itself, no dns, no tunnel, nothing to break&lt;/span&gt;
http://127.0.0.1:3001/api/push/xxxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The push token belongs to the Kuma instance, not the domain, so the same token works over loopback. Now the health ping does not care what my domain is or whether the tunnel is even up. It is the kind of fix that makes the original setup look a little silly in hindsight, which is usually a sign you got it right this time.&lt;/p&gt;

&lt;h2&gt;
  
  
  the part where i nearly lost the status page
&lt;/h2&gt;

&lt;p&gt;Okay. The embarrassing one. The reason this blog has a scar.&lt;/p&gt;

&lt;p&gt;I wanted my public status page to show up on the root of the status domain instead of the login dashboard. Uptime Kuma supports this through a setting. The clean way to change it is the web interface. I did not do the clean way. I decided to poke the setting straight into Kuma's SQLite database, because I had already been editing the database to add monitors and it had gone fine.&lt;/p&gt;

&lt;p&gt;Kuma runs SQLite in WAL mode. I stopped the container, ran my little update, and got back the four words you never want from a database.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;database disk image is malformed&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Kuma would not start. The page was gone. And the backup I had taken earlier turned out to be corrupt as well, because I had copied the database file while Kuma was still running, which with WAL mode can hand you an inconsistent snapshot. So now I had two bad copies and a service that would not come up. Lovely.&lt;/p&gt;

&lt;p&gt;The thing that saved me was SQLite's own recovery mode. It reads whatever it can out of a damaged file and rebuilds a clean one.&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;# pull the readable bits out of the broken db into a fresh, healthy one&lt;/span&gt;
sqlite3 kuma.db &lt;span class="s2"&gt;".recover"&lt;/span&gt; | sqlite3 recovered.db

&lt;span class="c"&gt;# then actually check it is clean before trusting it&lt;/span&gt;
sqlite3 recovered.db &lt;span class="s2"&gt;"PRAGMA integrity_check;"&lt;/span&gt;   &lt;span class="c"&gt;# want: ok&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It came back &lt;code&gt;ok&lt;/code&gt;, and almost everything survived. The one casualty was the status page row itself, sitting on exactly the pages that had gone bad. So I rebuilt that one record by hand, set it as the entry page, grouped the public services properly, and brought Kuma back up. Page restored.&lt;/p&gt;

&lt;p&gt;The lesson is not "SQLite is fragile". SQLite is wonderful. The lesson is do not hand-edit the live database of a running app just because the table is right there and it feels faster. Use the interface it gives you. And if you absolutely must touch the file, stop the app cleanly, checkpoint the WAL, take the backup from the stopped state, and run an integrity check before you trust anything. I knew all of this. I skipped it anyway because I was on a roll. That is exactly when it bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  cutting over and removing the old domain
&lt;/h2&gt;

&lt;p&gt;Once the new domain had been answering for everything, and I had actually used it for a bit rather than just curl-tested it, it was time to retire the old one.&lt;/p&gt;

&lt;p&gt;This was the easy bit, finally. I pulled the &lt;code&gt;vinelabs.de&lt;/code&gt; hostnames out of the tunnel config, leaving only the &lt;code&gt;vinelab.in&lt;/code&gt; ones, and reloaded the tunnel. My cloudflared runs under a launchd agent, so the reload was just a matter of the process restarting and reading the trimmed config on the way up. A quick check of every service on the new domain, all green, done.&lt;/p&gt;

&lt;p&gt;The old domain still exists. It just does not point at the homelab anymore, and it is not retired either. It is waiting on DENIC, and once that clears it goes back to work for the German related projects it was always a better fit for. The homelab got the right name. The &lt;code&gt;.de&lt;/code&gt; is getting the right job. I would call that a clean trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  what i would tell myself before starting
&lt;/h2&gt;

&lt;p&gt;If I could send a note back to the version of me who started this, it would be short.&lt;/p&gt;

&lt;p&gt;Run both domains at the same time, there is no prize for flipping the switch in one move. Remember that the tunnel config and DNS are two different jobs and both have to be done. Check the few services that actually embed their own domain, like Vaultwarden, because those are the ones that bite. Point internal health pings at loopback, not at your own public domain, because a service should not need the open internet to talk to its neighbour. And do not get clever with a live database when a perfectly good settings page is sitting right there.&lt;/p&gt;

&lt;p&gt;None of this was hard. The only genuinely scary part was self-inflicted, which is honestly how most of my homelab scares go.&lt;/p&gt;

&lt;p&gt;So that is where I will stop. If you have a cleaner way of handling a domain move on a tunnel setup, 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>homelab</category>
      <category>cloudflaretunnel</category>
      <category>dns</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>What do you do when your tool works but the people you built it for can't open a terminal?</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Tue, 02 Jun 2026 12:49:36 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/what-do-you-do-when-your-tool-works-but-the-people-you-built-it-for-cant-open-a-terminal-93h</link>
      <guid>https://dev.to/vineethnkrishnan/what-do-you-do-when-your-tool-works-but-the-people-you-built-it-for-cant-open-a-terminal-93h</guid>
      <description>&lt;h1&gt;
  
  
  What do you do when your tool works but the people you built it for can't open a terminal?
&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%2Fmedix-gui-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%2Fmedix-gui-hero.png" alt="A clean local web app open in a browser on a Mac, showing media files being converted with live progress bars, warm desk light, the terminal sitting quietly in the background." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I quietly ignored for a while
&lt;/h2&gt;

&lt;p&gt;Medix did its job. It is a small Python CLI that wraps ffmpeg, and the first time it earned its keep was converting an old wedding video so my family could finally watch it. That story already has its own post, so I will not drag you through it again.&lt;/p&gt;

&lt;p&gt;But here is the thing I kept not saying out loud.&lt;/p&gt;

&lt;p&gt;I built medix for myself. To be clear about that. The family video was the spark, but the tool that came out of it was always mine to run. I never handed the CLI to anyone. I never expected to.&lt;/p&gt;

&lt;p&gt;Because when I say "just run &lt;code&gt;medix ./video.vob&lt;/code&gt; and pick mp4", I am speaking a language that maybe three people in my family understand, and two of them are me on different days. Handing them a CLI would not be a gift, it would be homework. So I never did. The deal was simple: they bring me the file, I run the thing, they get the video.&lt;/p&gt;

&lt;p&gt;But somewhere along the way I started wondering what it would take to actually let them run it themselves. Not the terminal. Something they could open without me sitting next to them.&lt;/p&gt;

&lt;p&gt;A terminal, to most of my family, looks like the screen hackers use in movies right before something explodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  I already had the hard part
&lt;/h2&gt;

&lt;p&gt;So one of those evenings where you start "looking" at your own project and end up rewriting it, a thought hit me. The actual hard work was already done.&lt;/p&gt;

&lt;p&gt;The file discovery, the ffprobe parsing, resolving output paths, running ffmpeg and reading its progress, all of that lives in the engine. The CLI is just a face on top of it. A nice face, sure, but still just a face.&lt;/p&gt;

&lt;p&gt;If the CLI is one face, why can't there be a second one?&lt;/p&gt;

&lt;p&gt;That became the rule for the whole thing: &lt;strong&gt;one engine, two faces.&lt;/strong&gt; The GUI does not get its own clever conversion logic. It calls the exact same &lt;code&gt;discover_files&lt;/code&gt;, the exact same &lt;code&gt;convert_file&lt;/code&gt; the CLI calls. Same output, byte for byte. If I fix a bug in the engine, both faces get the fix. If the GUI did its own thing, I would be maintaining two tools that slowly drift apart and lie to each other. No thanks.&lt;/p&gt;

&lt;p&gt;Once you frame it like that, the GUI stops being a big scary project. It is just a web page that pokes the engine I already trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  No React. No Electron. No node_modules black hole.
&lt;/h2&gt;

&lt;p&gt;Now, the obvious modern move here is to reach for a framework. Spin up React, maybe Electron so it feels like a "real app", bundle the whole thing.&lt;/p&gt;

&lt;p&gt;I looked at that path for a bit and walked away.&lt;/p&gt;

&lt;p&gt;This is a tool for converting a video on your own machine. It does not need a build step, a bundler, a state management library, and three hundred megabytes of &lt;code&gt;node_modules&lt;/code&gt; so that someone's aunt can turn a .mov into an .mp4. The weight would be bigger than the thing it does.&lt;/p&gt;

&lt;p&gt;So the GUI is plain HTML, plain CSS, and plain JavaScript. Material Design styling, hand written, no toolkit. The server is Python's own &lt;code&gt;http.server&lt;/code&gt;, the same standard library that ships with the language. Open the folder, read the files, done. If you clone medix, there is nothing extra to install for the GUI. It is just there.&lt;/p&gt;

&lt;p&gt;I am not saying frameworks are bad. I am saying not every nail needs the big hammer, and a local media converter is a very small nail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cursed file picker saga
&lt;/h2&gt;

&lt;p&gt;Here is where I lost more time than I will admit.&lt;/p&gt;

&lt;p&gt;A web page, for very good security reasons, cannot pop open your OS file browser and read a real path off your disk. The browser hands you a sandboxed file, not a path. But medix works on paths. It needs to know &lt;em&gt;where&lt;/em&gt; your file actually lives so ffmpeg can read it and write the output next to it.&lt;/p&gt;

&lt;p&gt;I did not want to pull in tkinter or some GUI toolkit just to show one "choose a file" dialog. That felt like buying a truck to carry a single grocery bag.&lt;/p&gt;

&lt;p&gt;So the GUI shells out to whatever native dialog the operating system already has. On macOS that means asking AppleScript, of all things:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# yes, we are literally asking osascript to open a file dialog for us
&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POSIX path of (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;chooser&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; with prompt &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_run_picker&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;osascript&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows it spins up a PowerShell one-liner that summons a &lt;code&gt;System.Windows.Forms.OpenFileDialog&lt;/code&gt;. On Linux it tries &lt;code&gt;zenity&lt;/code&gt;, and if that is not around, &lt;code&gt;kdialog&lt;/code&gt;. One feature. Three completely different shell-outs to three completely different worlds.&lt;/p&gt;

&lt;p&gt;And the honest part? It feels wrong. A web app reaching out through a subprocess to ask the operating system to draw a file dialog, then catching the path it prints back, is the kind of thing that makes you pause and go "surely there is a cleaner way." There probably is. But this one works on all three, needs zero extra dependencies, and the user just sees a normal file picker. Cursed, but it ships.&lt;/p&gt;

&lt;p&gt;Tell me I am not the only one who has shipped something that works perfectly while quietly feeling a little dirty about how.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit I actually wanted: watching it convert, live
&lt;/h2&gt;

&lt;p&gt;This was the real itch. In the CLI you get progress bars in the terminal, which I love. But I wanted that same live feeling in the browser. A bar per file, an overall bar, status moving from queued to encoding to done, all updating as ffmpeg chews through your media.&lt;/p&gt;

&lt;p&gt;For that the server streams progress to the page using Server-Sent Events. The browser opens one long-lived connection, and the server just keeps pushing little updates down it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# one open pipe, keep nudging the browser as each file moves along
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SSE is lovely when it works and quietly annoying when it does not, because a stream that silently stops looks exactly like a stream that is just being slow. I went back and forth getting the per-file callback to fire at the right moments and flush instead of sitting in a buffer. Once it clicked, though, watching those bars crawl across the browser in real time was the moment the GUI stopped feeling like a toy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it something they never even have to start
&lt;/h2&gt;

&lt;p&gt;A GUI you launch from a terminal is still, technically, a terminal task. If my whole point is "non-technical people should be able to use this", then telling them to open a terminal and type &lt;code&gt;medix-gui&lt;/code&gt; defeats the entire idea.&lt;/p&gt;

&lt;p&gt;So the GUI can run as a background daemon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;medix-gui start      &lt;span class="c"&gt;# runs detached, prints the pid and port&lt;/span&gt;
medix-gui status     &lt;span class="c"&gt;# is it alive? what port?&lt;/span&gt;
medix-gui stop       &lt;span class="c"&gt;# done for the day&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on macOS it goes one step further with a launchd service. Install it once, and the GUI starts at login, restarts itself if it crashes, and survives reboots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;medix-gui install-service     &lt;span class="c"&gt;# set it up once&lt;/span&gt;
medix-gui uninstall-service   &lt;span class="c"&gt;# change your mind later&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dream is simple. Someone non-technical opens their browser, the page is already there at a local address, they drag in a video, pick a format, watch the bars, done. They never see Python. They never see ffmpeg. They never know there was a daemon quietly waiting for them the whole time. That, to me, is the tool working the way the CLI worked for the wedding video, except now I am not the one who has to run it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A local server is still a server
&lt;/h2&gt;

&lt;p&gt;One thing I did not want to get casual about: just because it runs on your own machine does not mean it gets to be careless.&lt;/p&gt;

&lt;p&gt;The whole privacy pitch of medix is that nothing leaves your computer. No upload, no login, no random server touching your files. A local web GUI could quietly undo all of that if I was sloppy. So it binds to &lt;code&gt;127.0.0.1&lt;/code&gt; only, rejects requests with a Host header that is not localhost, blocks cross-origin POSTs, and only serves files from a fixed allowlist instead of whatever path someone asks for. Boring, defensive plumbing. But "it runs locally" and "it is safe" are not the same sentence, and I did not want to pretend they were.&lt;/p&gt;

&lt;p&gt;Your files stay yours. That was the point of the CLI, and it stays the point of the GUI.&lt;/p&gt;

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

&lt;p&gt;Here is the part I have to be straight about.&lt;/p&gt;

&lt;p&gt;Nobody non-technical has actually used it yet.&lt;/p&gt;

&lt;p&gt;I built the whole thing ahead of the moment. The daemon, the launchd service, the live bars, the cursed file pickers, all of it sitting ready for the next time someone hands me a weird file and a hopeful look. As of now, the main person who uses the medix GUI is the same guy who wrote it, which was not exactly the plan.&lt;/p&gt;

&lt;p&gt;But I am oddly fine with that. Some tools you build for a problem you have right now. This one I built for a problem I know is coming, because in my family it always comes back. There will be another old video, another wrong format, another "can you just put it somewhere we can all watch it." And when that day shows up, the face will already be there, waiting in a browser tab, no terminal required.&lt;/p&gt;

&lt;p&gt;If you want to poke at it, medix is on PyPI (&lt;code&gt;pip install medix&lt;/code&gt;) and the source is at &lt;a href="https://github.com/vineethkrishnan/medix" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/medix&lt;/a&gt;. The full docs, including a proper guide for the GUI, daemon, and the launchd bit, live at &lt;a href="https://medix.vinelabs.de" rel="noopener noreferrer"&gt;medix.vinelabs.de&lt;/a&gt;. The GUI itself is just &lt;code&gt;medix-gui&lt;/code&gt; once it is installed.&lt;/p&gt;

&lt;p&gt;So yeah, that is my take on giving a CLI a second face. Yours might be completely different, and that is exactly what makes this whole space fun. Catch you in the next one, probably when something else I built for nobody finally finds its person.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ffmpeg</category>
      <category>gui</category>
      <category>opensource</category>
    </item>
    <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>
  </channel>
</rss>
