<?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 expense app stopped asking the network for permission</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sat, 01 Aug 2026 15:29:51 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/my-expense-app-stopped-asking-the-network-for-permission-gj4</link>
      <guid>https://dev.to/vineethnkrishnan/my-expense-app-stopped-asking-the-network-for-permission-gj4</guid>
      <description>&lt;h1&gt;
  
  
  My expense app stopped asking the network for permission
&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-expense-app-stopped-asking-the-network-for-permission-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-expense-app-stopped-asking-the-network-for-permission-hero.png" alt="A phone on a warm wooden table showing an expense app with an entry just saved and no signal bars, a small stack of glowing cards queued behind it, a faint teal thread rising from the stack towards two other phones sitting out of focus. Cinematic realistic photo, moody warm lighting, teal accents." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: My household expense app used to fail the save outright when there was no network. So I moved writes into a persisted outbox that drains later, which made saving instant with or without signal. The queue was the easy part. The real work was making reads count the queued entries, generating ids on the phone so a record can be edited before the server has ever seen it, and telling apart "the request never left" from "the server said no". I also skipped the connectivity library on purpose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;WeSpend is a small app I built for my house. One person puts money into a shared pot every month, everybody logs what they spend from their own phone, and at the end of the week it works out who owes whom.&lt;/p&gt;

&lt;p&gt;For a long time it had one rule I was not proud of. No network, no expense.&lt;/p&gt;

&lt;p&gt;You type the amount, you hit save, and it fails. Not queued somewhere quietly. Not kept in a corner for later. Just failed, and whatever you typed was gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody adds the expense later
&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%2Fvineethnk.in%2Fblog%2Fmy-expense-app-stopped-asking-the-network-for-permission-failed-save.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-expense-app-stopped-asking-the-network-for-permission-failed-save.png" title="inset" alt="A hand holding a phone in a dim kitchen, the screen dark with a single red warning triangle and no signal bars, paper grocery bags and a wallet sitting blurred on the counter behind." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the part which actually hurt.&lt;/p&gt;

&lt;p&gt;Think about when you add an expense. You are standing somewhere, bags in one hand, phone in the other, and you have maybe ten seconds of patience for this. The save fails. You tell yourself you will add it later.&lt;/p&gt;

&lt;p&gt;You do not add it later. Nobody adds it later. I built the app and even I do not add it later.&lt;/p&gt;

&lt;p&gt;So the money got spent, the entry never happened, and by the weekend the settlement was quietly wrong. Not loudly wrong, which would have been fine. Quietly wrong, which is much worse, because everybody looks at the number and believes it.&lt;/p&gt;

&lt;p&gt;An app that tracks money only works if people trust the total. And the total was only as good as the weakest network moment in the whole week.&lt;/p&gt;

&lt;p&gt;So the fix looked obvious. Save it on the phone, send it later. One evening of work, easy.&lt;/p&gt;

&lt;p&gt;It was not one evening of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Saving turned out to be the easy half
&lt;/h2&gt;

&lt;p&gt;The queue part was genuinely quick. A stored outbox, writes go in, something drains them in the background. Done.&lt;/p&gt;

&lt;p&gt;Then I switched off the network, saved an expense, and the app told me my budget had not moved at all.&lt;/p&gt;

&lt;p&gt;Which, fair enough. The budget was reading rows from the server. My expense was sitting in a queue that the read side had never heard about. Same problem in the history list. Same problem in the settlement, and the settlement is the entire point of the app.&lt;/p&gt;

&lt;p&gt;So the reads had to change too. Now every read takes the server rows and folds the queued operations on top before anything reaches the screen. An expense you saved with no signal counts against the budget, sits in your history, and shows up in the settlement exactly like one that synced days ago.&lt;/p&gt;

&lt;p&gt;That is the bit I did not see coming. Offline is not really a write feature. It is a read feature wearing a write feature's clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The id has to come from the phone
&lt;/h2&gt;

&lt;p&gt;This one small decision quietly shapes everything else.&lt;/p&gt;

&lt;p&gt;If the server hands out the id, then your entry has no name until the server has seen it. And a thing with no name cannot be edited. Cannot be deleted either. You are just holding it and waiting.&lt;/p&gt;

&lt;p&gt;So creates now make their own id on the device, and every repository moved from an insert to an upsert on that id.&lt;/p&gt;

&lt;p&gt;Two good things came out of that one change.&lt;/p&gt;

&lt;p&gt;You can edit or delete a row that has never once reached the server, because it already has a name of its own.&lt;/p&gt;

&lt;p&gt;And if a create gets sent, the connection dies before the reply comes back, and the retry sends it again, the second one just lands on the same id. No duplicate. In an app where the whole family sees the same list, one grocery run counted twice is not a small bug. That is how you start an argument at home about money that was never actually spent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three edits, one request
&lt;/h2&gt;

&lt;p&gt;Once saving became instant, we all started doing what people naturally do. Put in a number, look at it, realise it was wrong, fix it. Then fix it again.&lt;/p&gt;

&lt;p&gt;Done naively, that is three requests standing in line, waiting to be replayed at the server, for one entry that only ever needed one.&lt;/p&gt;

&lt;p&gt;So the queued operations now fold into each other. An edit merges into the create or the edit sitting ahead of it. Delete something that never synced and the whole chain just disappears, because there is nothing at the server to go and delete.&lt;/p&gt;

&lt;p&gt;Fiddle with a form three times and it still costs exactly one request when the signal comes back. If you have ever watched a sync queue faithfully replay a pile of operations that cancelled each other out, you already know why I bothered.&lt;/p&gt;

&lt;h2&gt;
  
  
  "No network" and "no" are two different answers
&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%2Fvineethnk.in%2Fblog%2Fmy-expense-app-stopped-asking-the-network-for-permission-two-failures.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-expense-app-stopped-asking-the-network-for-permission-two-failures.png" title="inset" alt="A dark wet street splitting in two, one side blocked by a glowing red barrier gate, the other side holding a patient line of small glowing teal cards waiting in the rain." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the part I got wrong on the first attempt, and it is the part that decides whether your queue is trustworthy or just a nice place for data to go missing.&lt;/p&gt;

&lt;p&gt;A request can fail in two completely different ways.&lt;/p&gt;

&lt;p&gt;One, it never left the phone. No signal, radio off, whatever. Nothing is wrong with the operation itself. Retrying later is exactly right.&lt;/p&gt;

&lt;p&gt;Two, it left, reached the server, and the server said no. Bad payload, household deleted, something genuinely broken. Retrying this forever is pointless, because the answer will be no every single time.&lt;/p&gt;

&lt;p&gt;So there is a check now that splits the two. A transport failure ends the drain immediately and everything stays queued with no attempt counted against it, because punishing an entry for the basement having no signal makes no sense. A real rejection from the server counts as an attempt, and after five of those the operation gets marked as blocked and shown to you in the app, with a retry button and a discard button.&lt;/p&gt;

&lt;p&gt;The rule I set for myself was simple. Nothing disappears silently. If the app cannot save something, you get told, and you decide what happens to it.&lt;/p&gt;

&lt;p&gt;An expense tracker that loses one entry without saying anything is worse than one that never worked at all. At least the broken one is honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  I did not install a connectivity library
&lt;/h2&gt;

&lt;p&gt;The usual move here is to add a module that tells you when the network is back, and drain the queue on that signal. I skipped it.&lt;/p&gt;

&lt;p&gt;Two reasons, and honestly the second one mattered more to me.&lt;/p&gt;

&lt;p&gt;First, a failed request already tells you everything that check would have told you. If the send fails at the transport layer, you have no network. You just learned it without asking anyone. A reachability API is a second opinion on a question you already answered.&lt;/p&gt;

&lt;p&gt;Second, adding a native module to an Expo app means a proper store build. Skipping it meant the whole thing could go out over the air as a JS bundle, and everybody at home would get it by simply reopening the app. No sending an APK around, no explaining to four people why they need to install something again on a Sunday. Before publishing I checked the native surface for anything that had crept in, it came back empty, and it went out as an over-the-air update.&lt;/p&gt;

&lt;p&gt;So instead of a connectivity listener, the drain runs on three triggers. When you queue something. When the app comes back to the foreground. And on a timer while anything is still pending.&lt;/p&gt;

&lt;p&gt;Overlapping triggers were an obvious trap, so the drain holds on to its own running promise. A second trigger firing mid-drain joins the pass already running instead of starting another one and sending everything twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I asked myself, does this sync in a delay?
&lt;/h2&gt;

&lt;p&gt;Turned out to be a better question than I expected.&lt;/p&gt;

&lt;p&gt;When you are online there is no delay at all. Queueing writes to the store and starts the send on the same tick. The screen does not wait for it, which is what makes saving feel instant, but the request itself leaves right away.&lt;/p&gt;

&lt;p&gt;When you are offline, the answer was that timer. I had set it to twenty seconds, which sounded perfectly sensible while typing it and felt terrible while actually using the app. You come out of the basement, signal returns, and you sit there staring at a pending pill for what feels like ages. I brought it down to five.&lt;/p&gt;

&lt;p&gt;But the more useful part of that question was the answer I did not enjoy giving.&lt;/p&gt;

&lt;p&gt;There is no background sync. That timer is a JavaScript interval, and React Native slows or suspends those when the app is not in front of you, hard suspended on iOS. So if your phone finds signal while the app is closed in your pocket, nothing happens at all. The foreground trigger is what actually catches it the next time you open the app.&lt;/p&gt;

&lt;p&gt;The manual used to say the app "retries by itself", which is technically true and practically a lie. I changed it to say plainly that retrying happens while the app is open, and that a phone which gets signal back with the app closed will sync on the next launch with nothing lost.&lt;/p&gt;

&lt;p&gt;Writing down the limit you cannot fix is more useful than pretending it is not there. People will find it either way. The only thing you get to choose is whether they find it in your documentation, or in the one moment they really needed the app to have worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself before starting
&lt;/h2&gt;

&lt;p&gt;If you are about to make an app work without a network, the queue is not the project. Save your energy for the other three things.&lt;/p&gt;

&lt;p&gt;Reads have to know about queued writes, otherwise your app will lie to you in the calmest possible voice.&lt;/p&gt;

&lt;p&gt;Ids have to come from the device, otherwise you cannot touch your own entry until the server blesses it.&lt;/p&gt;

&lt;p&gt;And failures have to be sorted into "not your fault, try later" and "genuinely broken, please look at this", because putting both into one retry loop is exactly how data goes missing.&lt;/p&gt;

&lt;p&gt;The thing that made the app feel fast was not the sync engine at all. It was that saving stopped being a request to somebody else. It became a write to my own phone that happens to travel later.&lt;/p&gt;

&lt;p&gt;And the settlement at the end of the week is finally telling the truth, which was the whole point of building this thing.&lt;/p&gt;

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

</description>
      <category>reactnative</category>
      <category>offlinefirst</category>
      <category>expo</category>
      <category>sync</category>
    </item>
    <item>
      <title>A Field Guide to Open Source Cold Emails</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Wed, 22 Jul 2026 13:36:53 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/a-field-guide-to-open-source-cold-emails-3l2e</link>
      <guid>https://dev.to/vineethnkrishnan/a-field-guide-to-open-source-cold-emails-3l2e</guid>
      <description>&lt;h1&gt;
  
  
  A Field Guide to Open Source Cold Emails
&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%2Fa-field-guide-to-open-source-cold-emails-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%2Fa-field-guide-to-open-source-cold-emails-hero.png" alt="Photorealistic macro shot of three paper envelopes pinned to an aged corkboard like insect specimens in a naturalist's collection, each under a small brass pin with a blank label tag, warm lamp light, 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 open source repos started attracting cold emails. Three of them, three completely different species: a growth-marketing spammer with broken mail-merge, a well-documented identity scam, and an AI politely doing outreach at scale for a real project. The first two are easy once you know the tells. The third one is the interesting problem, because almost everything in it is genuine except the part where a human supposedly wrote it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I maintain a handful of small open source projects. One of them is a self-hosted password manager sitting at exactly one GitHub star. One star. And that one is probably me.&lt;/p&gt;

&lt;p&gt;So you can imagine my surprise when the emails started coming in. Actual emails, addressed to me, about my repos. For a moment it felt like the projects had made it.&lt;/p&gt;

&lt;p&gt;They had not. What I actually had was three different strangers, with three very different motives, all of whom had found my repos through some kind of automation. And picking them apart turned out to be genuinely fun. So here is the field guide, one specimen at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Specimen one: the growth hacker who could not finish a sentence
&lt;/h2&gt;

&lt;p&gt;The first email was flattering for about four seconds. Someone "found my project while browsing TypeScript projects" and wanted to help it grow. The line that broke the spell was this one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Something built to zero-knowledge password vault. Self-hosted, end-to-end encrypted, open source. has genuine utility."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that again. That is my GitHub repo description, pasted into a mail-merge template mid-sentence, grammar and all. Nobody who actually looked at the project wrote that sentence. A script scraped my description, jammed it into a template, and the template did not even bother to make it fit.&lt;/p&gt;

&lt;p&gt;The rest followed the standard shape. A mild neg to create urgency ("most projects at 1 star stay there forever" - rude, but fair). A vague promise of reach through Reddit, Discord, and developer forums. And the classic foot-in-the-door ask: "Mind if I share a short plan?" The plan, of course, is where the invoice lives.&lt;/p&gt;

&lt;p&gt;Here is the part that actually matters though. What they were selling is astroturfing - posting about your project in communities as if it happened organically. And my project is a password manager. The entire value of a password manager is trust. One "this is being astroturfed" comment thread on Reddit would outlive any stars it ever bought me. For a security tool, paid shilling is not just useless marketing, it is anti-marketing.&lt;/p&gt;

&lt;p&gt;Verdict: delete, do not reply. Replying only confirms your address is live, and these campaigns run on automated follow-up sequences anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Specimen two: the very generous stranger from Japan
&lt;/h2&gt;

&lt;p&gt;The second email arrived dressed as a collaboration opportunity. A developer based in Japan, ten plus years of experience, prominent companies, the local software market is facing challenges, and my GitHub profile "inspired" them to reach out. Would I like to collaborate and generate mutual revenue?&lt;/p&gt;

&lt;p&gt;Notice what is missing: any mention of anything I have built. The first spammer at least scraped my repo description. This one only needed my email address to exist.&lt;/p&gt;

&lt;p&gt;This is not garden-variety spam. It is a documented scam template. There is an entire Hacker News thread about it, plus GitHub community reports, with near-identical emails going around for a long time now - same structure, same "market faces challenges" line, rotating Japanese names. The pitch, if you engage, is that you become the client-facing partner. Your identity, your freelance accounts, your bank account, your face on calls, while they quietly do the work behind you for a revenue split.&lt;/p&gt;

&lt;p&gt;What you would actually be doing is fronting for someone hiding their real identity and location. This pattern is strongly associated with North Korean IT worker operations. The risks on your side are not "wasted time". They are identity fraud and money laundering exposure. That escalated quickly, no?&lt;/p&gt;

&lt;p&gt;I did ask myself the obvious question: what happens if I just click the link and look at their portfolio site? Short answer, almost certainly nothing dramatic. The site exists to make the persona look credible when you google them, not to attack your browser. But visiting still tells their server you are alive and curious, which moves you up the follow-up list. There is nothing to gain. The danger with these was never the link. It is the conversation that follows.&lt;/p&gt;

&lt;p&gt;Verdict: report as phishing, not just spam. And if your commit email is public on GitHub, this is your sign to switch on the private noreply address, because that is almost certainly where they harvested you.&lt;/p&gt;

&lt;p&gt;If you maintain anything public on GitHub, go open your spam folder right now. I would bet money at least one of these two is already sitting in there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Specimen three: the polite robot with a real repo
&lt;/h2&gt;

&lt;p&gt;The third email is the one that earned this blog post.&lt;/p&gt;

&lt;p&gt;It was about a different project of mine, a curated collection of MCP servers. And this email was good. It named the project. It listed the actual services the collection covers. It asked a genuinely substantive architecture question about where a certain kind of security boundary should live - in the collection's metadata, inside each server, or in the agent runtime. It linked a real open source project the sender was building, with regular commits, tests, and examples.&lt;/p&gt;

&lt;p&gt;No money ask. No identity ask. No broken grammar. A real person with a real repo asking a real question.&lt;/p&gt;

&lt;p&gt;Except.&lt;/p&gt;

&lt;p&gt;A quick GitHub search showed the same person had opened near-identical "question" issues across at least fifteen different MCP-related repos. Filesystem servers, sub-agent frameworks, alarm systems, and my personal favourite, an MCP server for Garry's Mod. Same template every time, with per-repo details filled in. In my case the service list was lifted straight from my README, with a couple of entries trimmed off the end to make it look hand-picked.&lt;/p&gt;

&lt;p&gt;So the whole thing was almost certainly LLM-generated outreach at scale. Crawl repos, summarise each README, generate a plausible thoughtful question, hope maintainers engage with the linked project and eventually adopt it or link back to it. Engagement farming, but wearing a lab coat.&lt;/p&gt;

&lt;p&gt;And here is what makes this specimen tricky: it is not malicious. The project being promoted is real. The question is even worth answering - the honest answer involves MCP tool annotations and would take three sentences. This is just what the first spammer's email looks like after someone hands the same job to a much better writer. The tells did not disappear, they moved. You can no longer find them in the grammar. You find them in the sender's activity across the rest of GitHub.&lt;/p&gt;

&lt;p&gt;Verdict: ignoring is fully defensible, since mass outreach earns no reply obligation. If the question genuinely interests you, answer it in public on a GitHub discussion instead of over email. The one thing to avoid is being gently nudged into adding someone's dependency to your project because the email flattered your README.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual field guide
&lt;/h2&gt;

&lt;p&gt;Boiling all three down, here is what I now check before spending any emotion on a cold email:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does it quote my repo back at me with broken seams?&lt;/strong&gt; Scraped description, mangled grammar, details that almost fit. That is mail-merge. Delete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it mention nothing I have built?&lt;/strong&gt; Pure profile-scrape flattery plus a vague revenue offer is a scam shape, and the well-documented ones escalate to identity fronting. Report as phishing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it look genuinely hand-written?&lt;/strong&gt; Trust, but search. Check the sender's public activity for the same message sent everywhere. In the LLM era, the writing quality tells you nothing. The distribution pattern tells you everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is my commit email public?&lt;/strong&gt; If yes, that is the tap these campaigns drink from. GitHub's private noreply email closes it for future commits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The uncomfortable takeaway is that rule three is only going to get harder. The badly-glued template email is a dying species. What replaces it reads like a thoughtful peer, cites your own work accurately, and asks questions you would actually enjoy answering. The only durable signal left is behaviour at scale, and checking that takes more effort than most of us will spend on a random Tuesday email.&lt;/p&gt;

&lt;p&gt;That is pretty much it from my side today. Let me know what you think, or if your own one-star repo has been getting fan mail too - those stories are always the best ones. See you soon in the next blog.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>spam</category>
      <category>scams</category>
      <category>github</category>
    </item>
    <item>
      <title>The image model that only runs on Apple silicon</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:28:39 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/the-image-model-that-only-runs-on-apple-silicon-49mj</link>
      <guid>https://dev.to/vineethnkrishnan/the-image-model-that-only-runs-on-apple-silicon-49mj</guid>
      <description>&lt;h1&gt;
  
  
  The image model that only runs on Apple silicon
&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-image-model-that-only-runs-on-apple-silicon-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-image-model-that-only-runs-on-apple-silicon-hero.png" alt="Photorealistic night desk scene, a silver MacBook Air on the left glowing with a freshly rendered neon street image on its screen, and on the right a dark rack-mount Linux server whose small monitor shows only a red terminal error line." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; - Ollama's image generation does not run on the llama.cpp engine. It runs on MLX, which is Apple's framework, which means Apple silicon only. Linux and Windows builds simply do not ship the library. The fix is not a fix, it is a different machine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So there I was, pretty excited. Ollama had shipped local image generation, I had a perfectly good Ubuntu server sitting there doing nothing much, and I thought this was going to be a nice evening. Pull a model, type a prompt, get a picture. How hard can it be.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama run x/flux2-klein &lt;span class="s2"&gt;"neon-lit street at night, photorealistic"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And what I got back was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: 500 Internal Server Error: mlx runner failed: Error: failed to
initialize MLX: failed to load MLX dynamic library (searched:
[/usr/local/lib/ollama /build/lib/ollama /dist/linux-amd64/lib/ollama
/dist/linux_amd64/lib/ollama]) (exit: exit status 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first reaction was the same as yours probably would be. Broken install. Missing package. Some library I forgot to apt-get. I was already mentally writing the &lt;code&gt;ldconfig&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;That was the wrong instinct, and it cost me a good bit of the evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the error properly, not emotionally
&lt;/h2&gt;

&lt;p&gt;Look at that error again. Really look at it.&lt;/p&gt;

&lt;p&gt;It is not saying "MLX is broken". It is saying it went looking for an MLX library in four different folders and found nothing. And one of those folders is literally named &lt;code&gt;dist/linux-amd64/lib/ollama&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the whole answer sitting right there in the path name. The Linux build of Ollama has a slot where the MLX library should go, and that slot is empty, because Linux builds do not ship one. There is nothing to install. There is no package. The thing I was looking for was never made for the machine I was on.&lt;/p&gt;

&lt;p&gt;Every dev who has spent an evening reinstalling something that was never installable is nodding right now.&lt;/p&gt;

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

&lt;p&gt;Here is the part I did not know, and it explains everything.&lt;/p&gt;

&lt;p&gt;When you run a normal text model on Ollama - your gemma, your llama, whatever - it goes through the usual engine that runs on basically anything. CPU, NVIDIA GPU, Apple GPU, does not matter much. That is the Ollama most of us know.&lt;/p&gt;

&lt;p&gt;Image models do not use that engine at all. They use a completely separate runner built on &lt;strong&gt;MLX&lt;/strong&gt;, which is Apple's own machine learning framework. And Apple's framework talks to Apple's GPU through Metal. That is not a preference or an optimisation. It is the only thing MLX knows how to talk to.&lt;/p&gt;

&lt;p&gt;So it is not "Ollama does not support Linux image generation yet" in the sense of a missing feature. It is more like the feature was built on a foundation that only exists on one platform. Ollama's own announcement puts it plainly: image generation works on macOS, with Windows and Linux "coming soon". That post went up in January. It is July now and that sentence has not changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it instead of trusting it
&lt;/h2&gt;

&lt;p&gt;I do not like taking a blog post's word for it, even an official one. So once I had Ollama installed on my Mac, I went digging into the app bundle to see whether the MLX story actually held up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find /Applications/Ollama.app &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*mlx*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And there it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/Applications/Ollama.app/Contents/Resources/mlx_metal_v3/libmlx.dylib
/Applications/Ollama.app/Contents/Resources/mlx_metal_v3/mlx.metallib
/Applications/Ollama.app/Contents/Resources/mlx_metal_v4/libmlx.dylib
/Applications/Ollama.app/Contents/Resources/mlx_metal_v4/mlx.metallib
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;.metallib&lt;/code&gt; file is the giveaway. Those are compiled Metal shaders - GPU code written in Apple's shading language, for Apple's GPU. There are two versions of it, &lt;code&gt;v3&lt;/code&gt; and &lt;code&gt;v4&lt;/code&gt;, because different generations of Apple chips want different Metal targets.&lt;/p&gt;

&lt;p&gt;You cannot ship that to Linux. There is no Metal on Linux to ship it to.&lt;/p&gt;

&lt;p&gt;The other small thing I noticed - on the Mac, &lt;code&gt;/usr/local/bin/ollama&lt;/code&gt; is just a symlink pointing into the app bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/usr/local/bin/ollama -&amp;gt; /Applications/Ollama.app/Contents/Resources/ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is a neat little detail, because it means the CLI and the app are the same binary. The Mac app is not a wrapper around a separate install. That is why installing the desktop app is enough, and why hunting for a Homebrew formula (which is what I tried first, obviously) is a dead end.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what actually works
&lt;/h2&gt;

&lt;p&gt;On the Mac side, it just runs. My machine is an M4 Air with 24 GB, and the 4B model renders comfortably without the fans even getting interested. There are two models to know about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;x/z-image-turbo&lt;/code&gt; - 6B, from Alibaba's Tongyi Lab, Apache 2.0, good at photorealistic stuff&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;x/flux2-klein&lt;/code&gt; - from Black Forest Labs, comes in 4B (Apache 2.0) and 9B (non-commercial licence)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I went with &lt;code&gt;flux2-klein:4b&lt;/code&gt; because the licence is clean and it is fast.&lt;/p&gt;

&lt;p&gt;Now, one thing that tripped me up when I tried to script it. The CLI renders the image &lt;strong&gt;inline in your terminal&lt;/strong&gt;. It looks lovely and it writes absolutely nothing to disk. Pipe it somewhere and you get nothing useful. So for anything automated, go through the HTTP API:&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;# note the field is "image", singular - I lost a few minutes to that one&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:11434/api/generate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"x/flux2-klein:4b","prompt":"your prompt","stream":false,
       "width":1216,"height":640,"steps":4}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.image'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; hero.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas packed in there. The response field is &lt;code&gt;image&lt;/code&gt;, not &lt;code&gt;images&lt;/code&gt; - singular, which is the opposite of what every other image API has trained you to expect. And a full render takes a couple of minutes, so if you are calling this from any tool with a default timeout, raise it. Otherwise you get a mysterious empty file and start blaming the model.&lt;/p&gt;

&lt;p&gt;The hero image at the top of this post came out of exactly that command, by the way. Photorealistic, generated on the same laptop I typed this on, no API key, no credits, no upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit I actually want you to take away
&lt;/h2&gt;

&lt;p&gt;The lesson here is not about Ollama. Ollama will ship Linux support eventually and this whole post becomes a historical footnote.&lt;/p&gt;

&lt;p&gt;The lesson is that I spent an evening trying to fix an install that was not broken. And the information I needed to stop doing that was printed in the error message, in the first line, in a folder path that said &lt;code&gt;linux-amd64&lt;/code&gt; right next to a thing called MLX. Two words I could have connected in about ten seconds if I had read instead of reacted.&lt;/p&gt;

&lt;p&gt;But no. Error appears, brain says "broken install", hands start typing &lt;code&gt;apt&lt;/code&gt;. That reflex has saved me plenty of times, which is exactly why it is dangerous - it fires before you have actually looked at anything.&lt;/p&gt;

&lt;p&gt;Now when something fails on a machine, my first question is not "what is missing here". It is "was this ever supposed to work here at all". Different question. Much cheaper to answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference, if you landed here from the error
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Getting &lt;code&gt;failed to load MLX dynamic library&lt;/code&gt; on Linux or Windows? Nothing is broken. Image generation is macOS-only right now, there is an open issue for it (ollama/ollama#16876), and reinstalling will not help.&lt;/li&gt;
&lt;li&gt;Have a Mac with Apple silicon? Install the desktop app, pull &lt;code&gt;x/flux2-klein:4b&lt;/code&gt;, done.&lt;/li&gt;
&lt;li&gt;Only have a Linux box with an NVIDIA card? Use ComfyUI or Hugging Face &lt;code&gt;diffusers&lt;/code&gt; instead. Same models, different runner, actually supported there.&lt;/li&gt;
&lt;li&gt;Text models are unaffected. Your gemma and llama setups on Linux are completely fine. This is an image-generation-only wall.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is where I will stop. If you have found a sensible way to run these image models on a Linux box without going the ComfyUI route, I would genuinely like to hear it - drop me a note. Otherwise, see you when the next interesting problem shows up.&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>mlx</category>
      <category>applesilicon</category>
      <category>localai</category>
    </item>
    <item>
      <title>Why Does Your AI Agent Forget Things Halfway Through?</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Sat, 18 Jul 2026 18:18:04 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/why-does-your-ai-agent-forget-things-halfway-through-2imf</link>
      <guid>https://dev.to/vineethnkrishnan/why-does-your-ai-agent-forget-things-halfway-through-2imf</guid>
      <description>&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%2Fai-context-scoping-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%2Fai-context-scoping-hero.png" alt="A wooden library card catalogue in a dim study, one drawer pulled open with neatly filed index cards inside, the drawer next to it jammed open and overflowing with crumpled paper, a monitor glowing softly in the background." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; - Agents do not forget because their memory is weak. They forget because the session ran well past the job it was opened for. I went back through 210 of my own sessions across every project. Only four ever ran out of room, and those four were exactly my four longest. The fix was not a smarter summary or a cleverer prompt. It was ending sessions sooner.&lt;/p&gt;

&lt;p&gt;You know that moment when your agent calmly suggests the exact thing you both ruled out a while back? Not a hallucination. Not the wrong file. Just a polite, confident proposal to do the one thing you already decided against, delivered in the same tone as everything else it says.&lt;/p&gt;

&lt;p&gt;That happened to me not too long ago, and it sent me down a small rabbit hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring version of the story
&lt;/h2&gt;

&lt;p&gt;I was deep into a long session on the new backend, sorting out a pricing endpoint. We had gone back and forth on it properly. The choice was between calling the existing dynamic pricing routine directly, or building a new JWT authenticated endpoint that wraps that routine internally. We went with the second one, and there were actual reasons behind it.&lt;/p&gt;

&lt;p&gt;Then auto compaction fired.&lt;/p&gt;

&lt;p&gt;The summary that came out the other side kept the decision. New endpoint, JWT, wraps the existing routine. All of that survived intact. What did not survive was the why. The alternatives we had considered, and the specific reasons each one lost, got compressed into nothing.&lt;/p&gt;

&lt;p&gt;Now here is the part that stuck with me. The agent kept following the decision. It just could not defend it anymore. So the moment I pushed back even slightly, it started sliding toward the option we had already thrown out, because from where it stood there was no longer any reason not to.&lt;/p&gt;

&lt;p&gt;I caught it quickly. I have one rule I genuinely do not bend, which is never assume anything and always check against real payloads and real responses. That rule is what surfaced it. And honestly? The whole thing cost me almost nothing. Mild irritation, one re-explanation, back to work.&lt;/p&gt;

&lt;p&gt;Tiny damage. But it bothered me more than the size of it deserved, because I realised I had no idea how often this was quietly happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I went and counted
&lt;/h2&gt;

&lt;p&gt;I pulled up my whole session history. Every project, work and personal, the whole lot. 210 sessions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Median session: 19 messages from me&lt;/li&gt;
&lt;li&gt;Ninetieth percentile: 174&lt;/li&gt;
&lt;li&gt;Longest: 1704&lt;/li&gt;
&lt;li&gt;Sessions that ever hit auto compaction: four&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four out of 210. Lower than I expected, and I will take it. But the number that actually made me sit up straight was a different one.&lt;/p&gt;

&lt;p&gt;Those four compacted sessions were exactly my four longest sessions. 1704, 588, 574, 546. Nothing else in the entire history came anywhere near.&lt;/p&gt;

&lt;p&gt;That is not coincidence and it is not bad luck. Compaction is not a random hazard that strikes when the model is in a mood. It is what happens when a session keeps running long after the job it was opened for finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compaction is a symptom, not a tool
&lt;/h2&gt;

&lt;p&gt;Most advice about context management is really advice about surviving a full window. Summarise better. Prune the history. Write a tighter system prompt. Learn the magic incantation that makes the summary keep the good bits.&lt;/p&gt;

&lt;p&gt;All of that treats a full window as a fact of life you work around. I do not think it is. In my own history it is a rare event that correlates almost perfectly with one specific mistake, which is letting a session outlive its task.&lt;/p&gt;

&lt;p&gt;So my whole approach shifted from managing context to not needing to manage it. If the window never fills, there is nothing to compress, and nothing to lose in the compression. The summary that never runs cannot drop your reasoning.&lt;/p&gt;

&lt;p&gt;The way you get there is not clever prompting. It is boring old scoping.&lt;/p&gt;

&lt;h2&gt;
  
  
  One ticket, one worktree, one session
&lt;/h2&gt;

&lt;p&gt;This is the rule, and it is genuinely the whole thing.&lt;/p&gt;

&lt;p&gt;One ticket gets one git worktree. That worktree gets one session. When the ticket is done, the session dies with it. I have twenty worktrees sitting on disk right now, most of them with their own small &lt;code&gt;_docs&lt;/code&gt; folder, and each one had a session that started and finished inside that boundary.&lt;/p&gt;

&lt;p&gt;The unit of work decides where the session ends. Not the context meter. Not a warning banner. The job itself.&lt;/p&gt;

&lt;p&gt;What this buys you is that the session never has to hold two jobs at once. It never accumulates the debris of a thing you finished a while ago and stopped caring about. The median session being 19 messages is not discipline on my part, it is just what happens when the boundary is drawn somewhere sensible.&lt;/p&gt;

&lt;p&gt;Anyone who has watched an agent confidently reference a file from a task they abandoned earlier that same sitting knows precisely which failure this prevents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What lives in a file instead
&lt;/h2&gt;

&lt;p&gt;If the session is short, the knowledge has to live somewhere that outlasts it. That somewhere is the filesystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLAUDE.md is a catalogue, not documentation.&lt;/strong&gt; This distinction took me a while to get right. It describes the project: structure, architecture, conventions, rules, guidelines. It is what the agent needs to know about the shape of the place before it touches anything. It is not the repo's documentation and it should never try to be. My global one sits at 229 lines. The project ones range from a single line up to 621, and the big ones are big because those projects genuinely have that many conventions worth stating, not because I dumped the docs in there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the actual documentation, separately.&lt;/strong&gt; One work repo has 123 markdown files in its knowledge base folder. Two others have 64 and 45 in their docs trees. That material is real and useful, and almost none of it belongs in permanent context. It gets read when it is relevant to the task at hand, and ignored the rest of the time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory files for facts that survive sessions.&lt;/strong&gt; Sixteen projects have a memory folder. One fact per file, with a small index file on top. Things like a build quirk, a preference I have stated once and do not want to state again, a decision that outlives the ticket that produced it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skills for anything I do more than twice.&lt;/strong&gt; Fifteen of them now. A skill is a workflow the agent loads when it needs it, rather than instructions I paste every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subagents for anything wide.&lt;/strong&gt; 44 of my sessions fanned out to subagents, 214 runs in total. When something needs a broad sweep across many files, that search runs in its own window and comes back with the conclusion. The searching does not pollute the session that asked for it.&lt;/p&gt;

&lt;p&gt;Every one of those is the same move. Keep it out of the always-on context, and pull it in only when it earns its place.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a long session is genuinely fine
&lt;/h2&gt;

&lt;p&gt;I do not want to turn this into a rule that pretends to have no exceptions.&lt;/p&gt;

&lt;p&gt;My longest session, the 1704 message one, was an autonomous build on a side SaaS project. Long stretches of work with a stable goal, where I actually did want continuity across the whole thing. It compacted, and that was the correct outcome for the shape of the work.&lt;/p&gt;

&lt;p&gt;The difference is whether the length comes from the task genuinely being that long, or from the session drifting into a second and third task that should have started fresh. The first one is fine. The second one is where things quietly go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually do now
&lt;/h2&gt;

&lt;p&gt;Ticket opens, worktree gets created, session starts. Ticket closes, session gets closed with it, and anything worth keeping goes into a file before it does. If the subject changes, that is a new session, no matter how much room is left on the meter.&lt;/p&gt;

&lt;p&gt;The uncomfortable part of writing this was seeing how little the actual incident cost me. I keep wanting to tell it as a bigger disaster. It just was not one. A decision survived compaction while its reasoning did not, I noticed within a few exchanges, and I moved on with my day.&lt;/p&gt;

&lt;p&gt;But that is exactly why it was worth chasing down. The cheap failures are the ones you get to learn from without paying for them, and the numbers behind that one turned out to be a much better argument than the story itself.&lt;/p&gt;

&lt;p&gt;So that is where I will leave it. If you scope your agent work differently, or you have found a case where a long session genuinely beats a short one, I would honestly like to hear about it. Otherwise, see you when the next interesting problem turns up.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>claudecode</category>
      <category>contextmanagement</category>
      <category>developerworkflow</category>
    </item>
    <item>
      <title>The .de domain I unblocked just to redirect it</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Mon, 13 Jul 2026 12:40:37 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/the-de-domain-i-unblocked-just-to-redirect-it-c37</link>
      <guid>https://dev.to/vineethnkrishnan/the-de-domain-i-unblocked-just-to-redirect-it-c37</guid>
      <description>&lt;h1&gt;
  
  
  The .de domain I unblocked just to redirect it
&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-de-domain-i-unblocked-just-to-redirect-it-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-de-domain-i-unblocked-just-to-redirect-it-hero.png" alt="A tall barrier gate lifting to reveal a signpost with a bent arrow pointing away to another road, flat illustration, soft colors, modern editorial style, clean design." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I own &lt;code&gt;vinelabs.de&lt;/code&gt;, a German domain. It went into a blocked state because DENIC could not verify my holder data, which is a real thing when you are not a German owner. A while later the block was lifted. And after all that, what did I do with the freshly freed domain? I did not build anything on it. I pointed it at another domain I own, &lt;code&gt;vinelab.in&lt;/code&gt;, with a Cloudflare redirect that runs entirely at the edge. This is the story, plus the one small DNS trick that makes the redirect work without ever touching a server.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So here is a fun thing nobody tells you when you buy a &lt;code&gt;.de&lt;/code&gt; domain from outside Germany.&lt;/p&gt;

&lt;p&gt;DENIC, the registry that runs &lt;code&gt;.de&lt;/code&gt;, actually cares who you are. Not in a vague terms-of-service way. In a "we need to verify the holder data on this domain and until we do, it is going nowhere" way. I am an Indian developer. I bought a German domain because the brand I want to put on some of my work sits in the DE and EU lane. All perfectly legitimate. But to DENIC I was just a holder record that did not fully check out yet, and so the domain sat there. Registered, mine, and blocked.&lt;/p&gt;

&lt;p&gt;If you have ever owned something official in a country you do not physically live in, you know the particular flavour of low-grade paperwork dread that comes with it. It is never dramatic. It is just a quiet "please confirm your details" that sits on your mind for longer than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "blocked" actually means for a .de
&lt;/h2&gt;

&lt;p&gt;This is not the same as the domain being taken away. The registration was fine. What was not fine was the holder data, the name and address tied to the domain, which DENIC could not verify to their satisfaction. So the domain went into a state where it exists on paper but does not do anything useful. You cannot really point it anywhere while it is in that limbo.&lt;/p&gt;

&lt;p&gt;The fix was not clever on my side. The holder data got verified, and one fine day I got the email from DENIC saying the block had been lifted. That was the whole resolution. No war story, no escalation, no support ticket saga. Just a verification going through and an email landing in my inbox.&lt;/p&gt;

&lt;p&gt;I did not fully trust the email though. Emails lie, or at least they get ahead of reality. So I went to check for myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it myself, because one email is not proof
&lt;/h2&gt;

&lt;p&gt;The nice thing about &lt;code&gt;.de&lt;/code&gt; is that DENIC exposes a real whois. So I asked it directly instead of believing a notification.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;whois vinelabs.de
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line that mattered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Domain:   vinelabs.de
Status:   connect
Nserver:  dns1.registrar-servers.com
          dns2.registrar-servers.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;connect&lt;/code&gt; is the healthy state. In DENIC terms it means the domain is registered and properly connected to the network, which is exactly the boring, working status you want. It is not &lt;code&gt;failed&lt;/code&gt;, which is the soft-blocked state where the nameservers or the holder data are not right. And it is not &lt;code&gt;free&lt;/code&gt;, which would mean nobody owns it. So &lt;code&gt;connect&lt;/code&gt; was the green light.&lt;/p&gt;

&lt;p&gt;The other thing I noticed: no A record, no AAAA record, no &lt;code&gt;www&lt;/code&gt; CNAME. Nothing was pointed anywhere. The domain was active but completely empty.&lt;/p&gt;

&lt;p&gt;Which, honestly, was perfect. An active domain with zero records is a clean slate. Nothing to collide with, nothing to migrate, nothing to break. I could point it wherever I wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The big decision: build nothing
&lt;/h2&gt;

&lt;p&gt;Here is where I will be honest with you, because that is the whole point of this blog.&lt;/p&gt;

&lt;p&gt;After all that verifying and waiting and checking whois like a nervous parent, I did not build a site on &lt;code&gt;vinelabs.de&lt;/code&gt;. No landing page. No product. No grand launch to justify the wait.&lt;/p&gt;

&lt;p&gt;I already have &lt;code&gt;vinelab.in&lt;/code&gt; running. That one is live, sitting behind Cloudflare, serving cleanly over HTTPS on both the apex and &lt;code&gt;www&lt;/code&gt;. So the sensible thing, the thing that took the least effort and made the most sense, was to just send &lt;code&gt;vinelabs.de&lt;/code&gt; over to it. One domain, one destination, done.&lt;/p&gt;

&lt;p&gt;So the payoff for unblocking a German domain was a redirect. That is it. A 301.&lt;/p&gt;

&lt;p&gt;Do you also do this, where you fight to unlock some capability and then use it for the most modest possible thing? Because I felt slightly silly, and slightly pleased, at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual trick: redirect at the edge, touch no server
&lt;/h2&gt;

&lt;p&gt;Now the interesting part, because there is one small thing here worth stealing.&lt;/p&gt;

&lt;p&gt;A redirect has to be answered by something. Normally you think, okay, I need a tiny server somewhere that receives the request and replies with "go over there". But I did not want to run a box just to bounce traffic. That is silly for a redirect.&lt;/p&gt;

&lt;p&gt;Cloudflare can do the whole thing at its edge, and the way you set it up looks slightly weird the first time. You give the domain a DNS record pointing at an IP that does not exist on purpose.&lt;/p&gt;

&lt;p&gt;I added &lt;code&gt;vinelabs.de&lt;/code&gt; to Cloudflare on the free plan, same account as &lt;code&gt;vinelab.in&lt;/code&gt;. The DNS scan found nothing to import, which I already knew. Then I added two records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A     @     192.0.2.1      Proxied (orange cloud)
CNAME www   vinelabs.de    Proxied (orange cloud)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;192.0.2.1&lt;/code&gt; is not a typo and it is not my server. It is a reserved address from a block the internet standards set aside for documentation and testing (&lt;code&gt;TEST-NET-1&lt;/code&gt;, from RFC 5737). It is guaranteed to never be a real host. So why point at it?&lt;/p&gt;

&lt;p&gt;Because the request never actually gets there. With the orange cloud on, Cloudflare sits in front of that record. The request hits Cloudflare's edge, the redirect rule fires, and the visitor gets bounced before anything ever tries to reach the origin. The dummy IP is just a placeholder so the DNS record exists and the proxy has something to attach to. If I left the cloud grey instead of orange, Cloudflare would step out of the way and actually try to reach &lt;code&gt;192.0.2.1&lt;/code&gt;, which would just hang. The orange cloud is the whole trick.&lt;/p&gt;

&lt;p&gt;Then I moved the nameservers. At Namecheap, where the domain is registered, I swapped from &lt;code&gt;registrar-servers.com&lt;/code&gt; over to the two Cloudflare nameservers it handed me (&lt;code&gt;coen.ns.cloudflare.com&lt;/code&gt; and &lt;code&gt;collins.ns.cloudflare.com&lt;/code&gt;). Since DENIC had just verified the holder data, this went through without any fuss.&lt;/p&gt;

&lt;h2&gt;
  
  
  The redirect rule itself
&lt;/h2&gt;

&lt;p&gt;With the domain in Cloudflare and active, the redirect is one rule.&lt;/p&gt;

&lt;p&gt;In Rules, Redirect Rules, a new rule that matches the hostname, then sends it on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;When incoming requests match:
    Hostname equals vinelabs.de
    OR Hostname equals www.vinelabs.de

Then:
    Type: Dynamic redirect
    Expression: concat("https://vinelab.in", http.request.uri.path)
    Status code: 301
    Preserve query string: ON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;concat&lt;/code&gt; is what keeps paths alive. Instead of dumping everyone on the homepage, it takes whatever path came in and sticks it onto &lt;code&gt;vinelab.in&lt;/code&gt;. So &lt;code&gt;vinelabs.de/foo&lt;/code&gt; becomes &lt;code&gt;vinelab.in/foo&lt;/code&gt;, not &lt;code&gt;vinelab.in&lt;/code&gt;. Preserve query string keeps the &lt;code&gt;?something=value&lt;/code&gt; bits too. Little details, but they are the difference between a redirect that respects the link someone clicked and one that throws it away.&lt;/p&gt;

&lt;p&gt;Last bit, HTTPS. Under SSL/TLS I set the mode to Full and turned on Always Use HTTPS. Universal SSL issued a certificate for the apex and &lt;code&gt;www&lt;/code&gt; on its own shortly after. So even a plain &lt;code&gt;http://&lt;/code&gt; request gets forced up to &lt;code&gt;https://&lt;/code&gt; and then redirected, with a valid cert the whole way. No browser warnings before the bounce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust, but verify (with dig and curl)
&lt;/h2&gt;

&lt;p&gt;I was not going to write "it works" without actually watching it work. So, the checks.&lt;/p&gt;

&lt;p&gt;Nameservers first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short NS vinelabs.de
&lt;span class="c"&gt;# coen.ns.cloudflare.com.&lt;/span&gt;
&lt;span class="c"&gt;# collins.ns.cloudflare.com.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delegation had propagated. Then the redirect itself, every case I could think of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://vinelabs.de
&lt;span class="c"&gt;# 301 -&amp;gt; location: https://vinelab.in/&lt;/span&gt;

curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://vinelabs.de
&lt;span class="c"&gt;# 301 -&amp;gt; location: https://vinelab.in/   (plain HTTP forced up and redirected)&lt;/span&gt;

curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://www.vinelabs.de/foo?x&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="c"&gt;# 301 -&amp;gt; location: https://vinelab.in/foo?x=1   (path and query both preserved)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every case behaved. Apex and &lt;code&gt;www&lt;/code&gt;, both HTTP and HTTPS, path carried through, query string carried through, and the &lt;code&gt;server: cloudflare&lt;/code&gt; header confirming it was all happening at the edge and never at some origin. The &lt;code&gt;https://&lt;/code&gt; calls completing at all told me the certificate was real, because curl would have failed the handshake otherwise.&lt;/p&gt;

&lt;p&gt;That is the full chain working. A German domain that spent a while blocked over holder data, now quietly forwarding every request to &lt;code&gt;vinelab.in&lt;/code&gt;, and not a single server involved in doing it.&lt;/p&gt;

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

&lt;p&gt;Two small things stuck with me.&lt;/p&gt;

&lt;p&gt;One, owning a &lt;code&gt;.de&lt;/code&gt; from outside Germany comes with a verification step that can freeze the domain, and there is nothing you can do to rush it. It is not a bug and it is not personal. DENIC just wants to know the holder is real. Once that clears, the domain behaves like any other. Knowing that ahead of time would have saved me some quiet worrying.&lt;/p&gt;

&lt;p&gt;Two, a redirect does not need a server. The reserved dummy IP plus an orange cloud plus one rule is enough to forward an entire domain, forever, for free, with valid TLS and no box to maintain. I keep being a little surprised by how much you can do at the edge with nothing running behind it.&lt;/p&gt;

&lt;p&gt;And the domain I waited on? It is a signpost now. Points at &lt;code&gt;vinelab.in&lt;/code&gt; and gets on with its life. Sometimes the anticlimactic ending is the correct one.&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>cloudflare</category>
      <category>dns</category>
      <category>denic</category>
      <category>domains</category>
    </item>
    <item>
      <title>The cron job that had no user</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Mon, 13 Jul 2026 12:30:04 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/the-cron-job-that-had-no-user-1k92</link>
      <guid>https://dev.to/vineethnkrishnan/the-cron-job-that-had-no-user-1k92</guid>
      <description>&lt;h1&gt;
  
  
  The cron job that had no user
&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-cron-job-that-had-no-user-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-cron-job-that-had-no-user-hero.png" alt="Flat editorial illustration of a developer at his desk at night, working on a laptop that shows a red locked-padlock icon, a small friendly white robot standing beside the desk with no keycard. Dim office, warm desk lamp glow, soft muted colors, modern minimal style." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I added a scheduled job to a multi-tenant NestJS backend. It kept failing with "Missing active user in context". The use cases were reading the current org from a per-request CLS store, and a cron has no request, so the store was empty. The fix was to open a fresh context per tenant with a system user before doing any work, and then to write a test that runs the real job under an empty context so nobody can quietly break it again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So there I was, reading the morning logs, and I find this line sitting there at some ungodly hour: &lt;code&gt;Missing active user in context&lt;/code&gt;. From a cron job. A job that runs on a timer, all by itself, while every human who could possibly be a "user" is fast asleep.&lt;/p&gt;

&lt;p&gt;An automated job being told it is not logged in. Take a second with that one.&lt;/p&gt;

&lt;p&gt;The funny part is the app was completely right to complain. I was the one who set it up wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that worked fine for months
&lt;/h2&gt;

&lt;p&gt;The backend is multi-tenant. Many organisations, one codebase, and every single read or write has to be scoped to one org. You never want tenant A accidentally seeing tenant B's data. That rule is basically the whole ballgame.&lt;/p&gt;

&lt;p&gt;So how does the app know which org a request belongs to? It uses CLS. If you have not run into it, CLS in NestJS (the &lt;code&gt;nestjs-cls&lt;/code&gt; package) is a nice wrapper over Node's &lt;code&gt;AsyncLocalStorage&lt;/code&gt;. Think of it as a little box that lives for the duration of one request. Something early in the request pipeline drops the authenticated user into that box, and anything running later in the same request can reach in and pull it back out. No prop-drilling the user through fifteen function calls. It is genuinely pleasant.&lt;/p&gt;

&lt;p&gt;There is a small service wrapping all of this. Simplified, the important bit looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;requireActiveUser&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;RequiredActiveUserContext&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;activeUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;activeUser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;activeUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// nobody in the box - we refuse to guess which tenant this is&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UnauthorizedError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Missing active user in context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;activeUser&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;activeUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the use cases lean on it. A typical one starts by asking "who am I acting as, and which org?" and goes from there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SomeCommand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contextService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requireActiveUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// ...everything below is scoped to that org&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is clean. It means no use case can accidentally run without a tenant. If the box is empty, you get a loud &lt;code&gt;UnauthorizedError&lt;/code&gt; instead of a silent data leak. For every HTTP request, this is exactly what you want.&lt;/p&gt;

&lt;p&gt;You can probably already see the trap I was about to walk into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I added a cron
&lt;/h2&gt;

&lt;p&gt;The feature was simple. Every so often, go clean up some stale records across all tenants. Standard housekeeping stuff. NestJS makes this a one-liner with &lt;code&gt;@Cron&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Cron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CronExpression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EVERY_30_MINUTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handleCron&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconcileEverythingUseCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks harmless. I wrote it, the tests I had were green, I shipped it, and I moved on with my life.&lt;/p&gt;

&lt;p&gt;Here is what I did not stop to think about. A cron job does not run inside a request. There is no login, no token, no middleware doing its thing before the handler fires. The timer just goes off and calls the method directly. Which means that little CLS box? Empty. Completely empty.&lt;/p&gt;

&lt;p&gt;So the very first thing the use case does - &lt;code&gt;requireActiveUser()&lt;/code&gt; - looks in the box, finds nothing, and throws. And because I had wrapped the cron body in a polite &lt;code&gt;try/catch&lt;/code&gt; that just logs the error, it did not crash anything loud. It just failed, quietly, over and over, on a timer, writing one sad line into the logs each time while I slept.&lt;/p&gt;

&lt;p&gt;If you have ever bolted a cron onto an app that was built request-first, you know this exact flavour of pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious fixes are wrong
&lt;/h2&gt;

&lt;p&gt;My first instinct was the lazy one. Just skip the check for crons, no? Read the org some other way and stop calling &lt;code&gt;requireActiveUser&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Bad idea. That check is load-bearing. It is the thing standing between "scoped to one tenant" and "oops, ran across all data with no scope". Weakening it to make a cron happy is how you turn a small bug into a data-isolation incident. Hard no.&lt;/p&gt;

&lt;p&gt;Second instinct: fake a user. Grab some admin account, shove it in the box, done. Also bad. Now your background job is impersonating a real human who did not do anything, audit logs get muddy, and the day that admin gets deactivated your cron mysteriously dies. You are just moving the problem somewhere darker.&lt;/p&gt;

&lt;p&gt;The real issue was never the check. It was that a cron genuinely has no user, and pretending otherwise is the mistake. What a cron actually has is a job to do on behalf of the system, for a specific tenant. So the context it needs is not a person. It is the system, scoped to an org.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running as the system
&lt;/h2&gt;

&lt;p&gt;The fix was to give the context service a second way in. Not "I am this logged-in human", but "I am the system, working on this org". Here is the shape of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;runAsSystem&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ifNested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;override&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// put a system identity in the box, scoped to one tenant&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;activeUser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SystemUser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cls.run&lt;/code&gt; opens a brand new box and runs your function inside it. Before running, we drop a &lt;code&gt;SystemUser&lt;/code&gt; in, carrying the one org this slice of work belongs to. Now when the use case calls &lt;code&gt;requireActiveUser&lt;/code&gt; (or whatever reads the org), the box is not empty anymore. It finds a legit system identity, gets the org id, and does its thing. No fake human. No skipped check. The safety rail stays exactly where it was.&lt;/p&gt;

&lt;p&gt;That one option in there - &lt;code&gt;ifNested: 'override'&lt;/code&gt; - is worth a mention. It says "even if somehow this runs inside an existing context, do not inherit the parent's box, start clean". For a background job you really do not want to accidentally pick up some leftover state from a context that opened earlier, like a database transaction that is still hanging around. Clean slate, every time. It is a small flag that saves you from a category of very confusing bugs later.&lt;/p&gt;

&lt;p&gt;And the cron itself becomes a loop, because a cron is not one tenant, it is all of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Cron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CronExpression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EVERY_30_MINUTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handleCron&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAllOrgsToProcess&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contextService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runAsSystem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconcileEverythingUseCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each org gets its own fresh context. The use cases underneath did not change at all - they still ask the box "which org?" and still get a real answer. The only thing that changed is who fills the box before they look. During a request, it is the logged-in user. During a cron, it is the system, one org at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually stops me repeating this
&lt;/h2&gt;

&lt;p&gt;Fixing the bug felt good for about a minute. Then the uncomfortable thought showed up. What stops future-me, six months from now, from adding a new cron and forgetting the &lt;code&gt;runAsSystem&lt;/code&gt; wrapper all over again?&lt;/p&gt;

&lt;p&gt;Because here is the nasty bit about this bug: it does not show up in normal tests. Most tests either call the use case directly with a user already prepared, or spin up a context on purpose. Both of those hide the exact thing that breaks in production, which is the empty box. The bug only appears when something runs with genuinely nothing in the context, which is precisely the one condition your happy-path tests never reproduce.&lt;/p&gt;

&lt;p&gt;So the guard had to reproduce that condition on purpose. The trick was a tiny helper that builds a real context service backed by a truly empty store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;createEmptyContextService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;ContextService&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ContextService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ClsService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AsyncLocalStorage&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No user, no request, no setup. Exactly what a cron sees at 2 AM. And then a test that wires up the real job with the real use case (repos mocked, everything else genuine) and asserts one thing - it does not blow up under an empty context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;runs under an empty context without UnauthorizedError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleCron&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;resolves&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBeUndefined&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone later adds a context-dependent call into that job's path and forgets to wrap it, this test goes red immediately, with a stack trace pointing right at the problem, in CI, long before it ever reaches a sleepy production log. That is the whole point. The bug is invisible in the wrong test and impossible to miss in the right one.&lt;/p&gt;

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

&lt;p&gt;The lesson that stuck was not really about CLS or crons. It was that "the current user" is an assumption baked so deep into a request-first app that you stop seeing it. Every use case quietly assumes somebody is logged in, because for years somebody always was. The moment you introduce an entrypoint that runs without a request - a cron, a queue worker, a CLI command, a webhook consumer - that assumption walks off a cliff, and it does it quietly, in a try/catch, where you will not notice until you happen to read the logs.&lt;/p&gt;

&lt;p&gt;So now, any time I add something that runs outside a request, the first question I ask is boring and useful: who is the context here, and who fills it before any real work starts? If I cannot answer that in one sentence, I am not ready to write the job yet.&lt;/p&gt;

&lt;p&gt;That is the story. A robot got told it was not logged in, and it was completely correct. If it saves you one confused morning squinting at "Missing active user in context", then writing this down did its job.&lt;/p&gt;

&lt;p&gt;Not going to pretend I designed this cleanly the first time. I shipped the broken version, the logs caught me, and the test only exists because the bug embarrassed me into writing it. But if even one part of this helped someone dodge the same 2 AM head-scratcher, then it was worth putting down. See you in the next one.&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>cron</category>
      <category>cls</category>
      <category>multitenant</category>
    </item>
    <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>
  </channel>
</rss>
