<?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>327 days of uptime was the bug</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Tue, 18 Aug 2026 09:40:38 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/327-days-of-uptime-was-the-bug-5f24</link>
      <guid>https://dev.to/vineethnkrishnan/327-days-of-uptime-was-the-bug-5f24</guid>
      <description>&lt;h1&gt;
  
  
  327 days of uptime was the bug
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngrq07sfriwqdfr6c3nk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngrq07sfriwqdfr6c3nk.png" alt="Flat editorial illustration of a dark data center aisle, rows of unlit server racks, one rack in the center glowing hot red with blue liquid overflowing out of it and pooling on the floor, a small red toggle switch beside it flipped off." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: A power event at our provider killed two production servers mid-log-line. The database box needed one button. The web box needed 10.5 hours, because a &lt;code&gt;redis-server&lt;/code&gt; unit had been sitting &lt;code&gt;disabled&lt;/code&gt; in systemd for years while the process ran happily by hand, and 327 days of uptime meant nobody ever found out. On the first boot in a year, sessions had no store, every request died inside &lt;code&gt;session_start()&lt;/code&gt;, and each PHP worker leaked about a gigabyte of native memory per second with &lt;code&gt;memory_limit&lt;/code&gt; set to 128M the entire time. The fix was two commands. Getting to those two commands was the whole story.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two servers stopped at the same second.&lt;/p&gt;

&lt;p&gt;Not "went unhealthy". Not "started throwing 500s". Stopped. Both boxes wrote a log line, and then the log file just ends, mid-sentence, like someone pulled the page out of the book. No shutdown sequence. No kernel panic. No OOM killer doing its thing. Nothing.&lt;/p&gt;

&lt;p&gt;When two independent machines in the same rack die at the exact same second, you can stop suspecting your code. Software is not that punctual. The evidence pointed at a power event on the provider side, and the ticket for that is still open at the time of writing.&lt;/p&gt;

&lt;p&gt;That was Sunday, just before midnight. Here is the genuinely embarrassing part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody noticed for seven hours
&lt;/h2&gt;

&lt;p&gt;We had monitoring. What we did not have was anything that could wake somebody up.&lt;/p&gt;

&lt;p&gt;The APM was doing its job perfectly, faithfully drawing a beautiful graph of traffic falling to zero and staying there all night, and paging exactly zero human beings. And there was no external uptime check at all, because who needs one when you already have an APM.&lt;/p&gt;

&lt;p&gt;So the outage was found Monday morning in the least sophisticated way available: the app was down.&lt;/p&gt;

&lt;p&gt;That part, honestly, did not rattle me. Outages happen. Degraded performance happens. Most of the time I am on the box and have things back within minutes, and unless a customer goes looking at the incident page or reads the email, they never even know there was a wobble. Being the person who quietly recovers from a rare bad morning is, more or less, the job.&lt;/p&gt;

&lt;p&gt;What rattled me was the next step. I went to open a shell on the box through our access gateway, listed the cluster, and it came back with every single server we run on it, except two. Not unreachable. Not unhealthy. Not sitting there red. Just absent from the list, like they had never been part of it.&lt;/p&gt;

&lt;p&gt;I have recovered servers plenty of times. I had never watched two of them quietly drop out of existence.&lt;/p&gt;

&lt;p&gt;Seven hours of downtime, and the thing that finally raised the alarm was two missing rows in a list. No alert ever fired.&lt;/p&gt;

&lt;p&gt;Sit with that for a second, because it is the cheapest lesson in this entire post. A basic external uptime check with a phone number attached costs almost nothing and would have turned seven silent hours into a couple of minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The certificate from 2023
&lt;/h2&gt;

&lt;p&gt;Before touching the provider console, we confirmed the box was actually dark and not just unhappy. Ping from home: 100% loss. TCP on 22 and 443: dead. The reverse tunnel the server itself maintains: gone. Three different vantage points, same answer.&lt;/p&gt;

&lt;p&gt;And then one probe came back weird. From a healthy sibling server inside the provider network, curl to the production domain did not time out. It answered. With a certificate that had expired in 2023.&lt;/p&gt;

&lt;p&gt;That is a genuinely upsetting thing to read at seven in the morning. For a moment it looked like the box was half alive and serving something ancient.&lt;/p&gt;

&lt;p&gt;It was not. That sibling box had an &lt;code&gt;/etc/hosts&lt;/code&gt; entry pointing the production domains at itself, left there by past-us for some forgotten reason, and a standby nginx on that box was quietly serving a three-year-old certificate to anyone who asked. A ghost from an old migration, answering the door.&lt;/p&gt;

&lt;p&gt;If your probe gives you an answer that makes no sense, check what your probe actually resolved before you build a theory on top of it. &lt;code&gt;getent hosts&lt;/code&gt; before &lt;code&gt;curl&lt;/code&gt;, always.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database took one button. The web box did not.
&lt;/h2&gt;

&lt;p&gt;Dedicated hardware, so recovery goes through the provider's control panel. Quick warning if you ever end up there in a hurry: the tabs that look like remote consoles are, on some providers, OS reinstallers. The button you want is the plain hardware reset. Read that panel twice when you are stressed, because the medicine and the loaded gun sit right next to each other in the same menu.&lt;/p&gt;

&lt;p&gt;The database server: one reset, clean InnoDB crash recovery, back and healthy and never a problem again. Zero data lost. That machine did its job.&lt;/p&gt;

&lt;p&gt;The web server booted too.&lt;/p&gt;

&lt;p&gt;And then it drowned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The box that fills 125 GB in about a minute
&lt;/h2&gt;

&lt;p&gt;Within a few minutes of every single boot, the same thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;load average around 550&lt;/li&gt;
&lt;li&gt;RAM at 124 of 125 GB&lt;/li&gt;
&lt;li&gt;swap completely full&lt;/li&gt;
&lt;li&gt;SSH sessions dying mid-command, roughly one in three getting through&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;htop&lt;/code&gt; showing a blank screen, because the machine was too starved to even paint it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You have to understand what debugging looks like in that state. You type a command. The characters appear on screen a second later, one at a time. You hit enter and maybe you get output, maybe the session just dies and you reconnect. Every piece of diagnosis in this story was collected in the gaps between dying shells.&lt;/p&gt;

&lt;p&gt;First theory, and honestly the obvious one: PHP-FPM was configured with 300 workers on a 125 GB box, and workers were sitting at about a gigabyte each. That maths does not work. So, restart FPM. Memory frees beautifully. About a minute later it is full again.&lt;/p&gt;

&lt;p&gt;Cap the pool to 100 workers. Full again, except now workers are at 1.2 GB each. The pool cap did not slow it down, it just made each worker fatter.&lt;/p&gt;

&lt;p&gt;If your first instinct reading that was to blame a runaway cron, hold onto that thought, because it was mine too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The process that would not stay dead
&lt;/h2&gt;

&lt;p&gt;Stop cron. Twelve seconds later, a single PHP process appears using 9.6 GB.&lt;/p&gt;

&lt;p&gt;Kill it. It comes back.&lt;/p&gt;

&lt;p&gt;Kill it again, stop supervisord (which manages the queue workers), assume that is that. Another one appears, this time with parent PID 1, which is systemd's way of saying "I have no idea either, ask around".&lt;/p&gt;

&lt;p&gt;This is where a command earned its place in my permanent toolkit:&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;# who actually owns this mystery process? systemd knows, just ask it&lt;/span&gt;
systemctl status &amp;lt;PID&amp;gt;

&lt;span class="c"&gt;# same question, different angle&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/&amp;lt;PID&amp;gt;/cgroup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;systemctl status&lt;/code&gt; with a PID instead of a unit name tells you which unit that process belongs to. It named the culprit instantly: a dedicated background service nobody had in mind, auto-starting at boot, respawning its worker every time we killed it.&lt;/p&gt;

&lt;p&gt;Lesson that cost us a good chunk of the morning: before you diagnose anything on a freshly booted box, park every spawner. Cron, supervisord, every custom systemd unit. They all wake up at boot and they will happily fight you for the RAM you are trying to measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  memory_limit is 128M and the worker is 9 GB
&lt;/h2&gt;

&lt;p&gt;Second hardware reset. This time everything background is parked and FPM is capped at 40 workers. Forty. On 125 GB.&lt;/p&gt;

&lt;p&gt;Then we sat there running &lt;code&gt;free -h&lt;/code&gt; and watching it climb between consecutive invocations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;13 GB -&amp;gt; 32 -&amp;gt; 50 -&amp;gt; 68 -&amp;gt; 88 -&amp;gt; 109 -&amp;gt; 124
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty gigabytes between two runs of the same command. It reads like a countdown in a horror movie, and it feels roughly the same.&lt;/p&gt;

&lt;p&gt;Now here is the number that finally broke the case open. PHP's &lt;code&gt;memory_limit&lt;/code&gt; on that box was &lt;code&gt;128M&lt;/code&gt;. It had been 128M the whole time, through every restart, every cap, every theory.&lt;/p&gt;

&lt;p&gt;Workers were at 3 to 9 GB each.&lt;/p&gt;

&lt;p&gt;A 128 MB limit and a 9 GB process are not a contradiction. They are a clue. &lt;code&gt;memory_limit&lt;/code&gt; does not limit memory, whatever the name suggests. It limits the Zend allocator, which is what your PHP code and its arrays and strings go through. Extensions and native library code allocating below that layer are completely invisible to it. If your worker is orders of magnitude past its limit, the leak is not in PHP. It is under PHP.&lt;/p&gt;

&lt;p&gt;You can prove that without guessing:&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;# what is this process actually holding, and what kind of memory is it?&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/&amp;lt;pid&amp;gt;/smaps_rollup

&lt;span class="c"&gt;# and what does it have open while it holds it?&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /proc/&amp;lt;pid&amp;gt;/fd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;smaps_rollup&lt;/code&gt; came back with about 7 GB of &lt;code&gt;Private_Dirty&lt;/code&gt; anonymous heap in a single worker. Anonymous means not file-backed, so it is not a giant file being mapped in. It is raw allocation.&lt;/p&gt;

&lt;p&gt;And the file descriptor list was spotless. &lt;code&gt;/dev/null&lt;/code&gt; on stdio and three sockets. That is it. No open files, no temp file being written, nothing being read from disk. Meanwhile &lt;code&gt;SHOW FULL PROCESSLIST&lt;/code&gt; on the database during a climb showed 140 connections, every last one of them sleeping.&lt;/p&gt;

&lt;p&gt;So: workers burning gigabytes of raw heap, not touching disk, not talking to the database. Whatever they were doing, they were doing it before any actual work started.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does every request touch first?
&lt;/h2&gt;

&lt;p&gt;Sessions.&lt;/p&gt;

&lt;p&gt;Where do our sessions live? Redis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;redis-cli ping
&lt;span class="c"&gt;# Connection refused&lt;/span&gt;

systemctl is-enabled redis-server
&lt;span class="c"&gt;# disabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Disabled.&lt;/p&gt;

&lt;p&gt;Someone, years ago, disabled the unit and started the process by hand. Maybe a maintenance window, maybe a config change, maybe a Tuesday. The process ran fine. It kept running fine for 327 days, because the box was never rebooted, and a process that is already running does not care one bit what systemd thinks about it.&lt;/p&gt;

&lt;p&gt;Then a power cut forced the first boot in a year, systemd read the config it had been given, and did exactly what it was told: it did not start Redis.&lt;/p&gt;

&lt;p&gt;From there the chain writes itself. No session store, so every incoming request fatals inside &lt;code&gt;session_start()&lt;/code&gt;. The failing session handler leaks native memory in the process, roughly a gigabyte per second per worker, below where &lt;code&gt;memory_limit&lt;/code&gt; can see it. Multiply by the worker pool and 125 GB of RAM disappears in about a minute. The box thrashes, which kills SSH and every observability tool on it, which is why nothing on the box could tell us what was happening on the box.&lt;/p&gt;

&lt;p&gt;There was one more twist that I still find funny in a grim way. The fatal error handler read the session again while writing the error to our custom log engine. So the error handling for the broken session broke on the broken session. Recursive misery.&lt;/p&gt;

&lt;p&gt;The fix, after two and a half hours of that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl start redis-server
systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;redis-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 444 keys were sitting safely on disk the whole time, completely untouched. Two commands. That is the entire fix for the chaos above.&lt;/p&gt;

&lt;p&gt;We brought it back up carefully from there, warming the caches before opening the doors to a night's worth of queued-up retry traffic. Total elapsed from the power cut: about 10.5 hours. The power cut itself was not our fault. Most of the rest of it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am actually taking from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Uptime is not a trophy, it is an unpaid bill.&lt;/strong&gt; 327 days of uptime means 327 days of never testing whether the machine can boot itself. A service that is running but disabled is a landmine that only a reboot can find, and reboots are exactly what a long-uptime box never gets. Go audit &lt;code&gt;systemctl is-enabled&lt;/code&gt; for everything you depend on, right now, and compare it against what is actually running. Then schedule reboot drills on staging so the discovery happens on a Tuesday afternoon instead of at seven on a Monday morning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;memory_limit&lt;/code&gt; does not limit memory.&lt;/strong&gt; It limits the Zend allocator. Native code allocates underneath it and does not check in. When the numbers make no sense, &lt;code&gt;smaps_rollup&lt;/code&gt; tells you what kind of memory it is, the fd list tells you what the process is holding, and between those two you usually know which layer to blame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size worker pools by memory, not by hope.&lt;/strong&gt; Max children times realistic worst-case RSS has to fit in RAM with room to spare. Ours assumed 400 MB workers forever and nobody re-checked that assumption for years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring nobody reads is decoration.&lt;/strong&gt; We had an APM drawing a perfect picture of the outage all night. It just never told anyone. A graph that only works when a human happens to look at it is not monitoring, it is art.&lt;/p&gt;

&lt;p&gt;That last one is the one I keep coming back to. Everything else in this incident was a technical problem with a technical fix. The seven hours of nobody knowing was a choice we made by not making it.&lt;/p&gt;

&lt;p&gt;Okay, that is enough from me. If any of this saves you an hour on a bad morning, that is the whole point of writing it down. Until the next one, take it easy.&lt;/p&gt;

</description>
      <category>incident</category>
      <category>postmortem</category>
      <category>php</category>
      <category>redis</category>
    </item>
    <item>
      <title>Every prompt I type is homework now</title>
      <dc:creator>Vineeth N K</dc:creator>
      <pubDate>Tue, 11 Aug 2026 15:25:02 +0000</pubDate>
      <link>https://dev.to/vineethnkrishnan/every-prompt-i-type-is-homework-now-mfd</link>
      <guid>https://dev.to/vineethnkrishnan/every-prompt-i-type-is-homework-now-mfd</guid>
      <description>&lt;h1&gt;
  
  
  Every prompt I type is homework 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%2Fevery-prompt-i-type-is-homework-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%2Fevery-prompt-i-type-is-homework-now-hero.png" alt="An adult developer squeezed into a small wooden school desk with a laptop, while a tall retro robot teacher marks his screen with a big red grading pen" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I asked Claude to build a skill that would teach me prompt engineering. It built a proper little school: a curriculum, a grading rubric, a student record file. But the part that actually changed how I write prompts was not the classroom. It was a hook the skill installed, which logs every prompt I type and quietly grades the thin ones at the end of Claude's reply, inside real work, without me asking for it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Earlier today I fired a quick one-line request into Claude Code. Routine work, the kind of prompt you type without thinking. It did the job fine, and then, at the very bottom of the reply, it added a small section that rewrote my own prompt back to me. Properly structured. Goal, scope, output format, the works. Like a teacher handing back an essay with red ink on it.&lt;/p&gt;

&lt;p&gt;I built this. Voluntarily. On purpose.&lt;/p&gt;

&lt;p&gt;Let me explain how I ended up in a school I constructed around myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  It started with a bad prompt, obviously
&lt;/h2&gt;

&lt;p&gt;A few weeks back, in the middle of some completely unrelated work, I asked Claude Code for something like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Build me a skill called coach-me-prompting. When I invoke it, Claude should act as my personal prompt engineering coach and train me to write better prompts over time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reads perfectly reasonable, no? It sounds like a person who knows exactly what he wants. I certainly thought so when I sent it.&lt;/p&gt;

&lt;p&gt;The coach disagreed, because the first thing the finished skill did was grade the prompt that created it. The student record it wrote has this line in it, which I have now read many times:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;person-ish, 5 invented decisions (location, format, persistence, grading, scope); worked only because executor defaults matched intent&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Five decisions I never stated. Where the skill should live. How lessons should work. How progress gets stored. How grading happens. What is even in scope. Claude invented all five, and the result matched what I wanted purely because its defaults happened to line up with the picture in my head. The coach called this "person-ish" prompting. As opposed to "machine-ish", which is what I claimed I wanted to be.&lt;/p&gt;

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

&lt;p&gt;The skill turned out more serious than I expected. There is a 10 module curriculum, from clarity and specificity up to loop engineering and evals. There is a grading rubric with 7 dimensions, each scored 1 to 5, with instructions to the coach that say things like "A 3 is a 3. Never inflate scores." There is a progress.md that tracks modules, weak spots, and every real prompt it has reviewed.&lt;/p&gt;

&lt;p&gt;And because I said I wanted to write prompts like specs, it set up a spec-first track built around one template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GOAL:       what to achieve, and WHY - one sentence each
CONTEXT:    facts the executor cannot know (files, versions, prior attempts)
TASK:       imperative instruction(s)
SCOPE:      what may be touched; what must NOT be touched
OUTPUT:     exact format, shape, length (schema if parsed by code)
EXAMPLES:   1-3 input-&amp;gt;output pairs when behavior is subjective
EDGE CASES: behavior when data is missing, ambiguous, or impossible
VERIFY:     how the executor proves success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every section either filled or explicitly waived. A silently missing section counts as a gap, same as a vague sentence. The curriculum even has a name for short prompts that pretend to be precise: "person-ish in a trench coat". I did not write that phrase. My own tooling wrote that phrase, about me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I did not fully think through
&lt;/h2&gt;

&lt;p&gt;A curriculum only works if the student shows up. So the skill also did something sneakier. It added a UserPromptSubmit hook to my Claude Code settings. That hook fires on every single prompt I type, in every project, and does two things:&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="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# every real prompt goes into the log, slash commands skipped&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select((.prompt | startswith("/")) | not)
  | {ts: now | todate, session: .session_id, cwd: .cwd, prompt: .prompt}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.claude/skills/coach-me-prompting/prompt-log.jsonl

&lt;span class="c"&gt;# and then it injects a standing instruction that tells Claude:&lt;/span&gt;
&lt;span class="c"&gt;# if this prompt was vague, do the task anyway, but end your reply&lt;/span&gt;
&lt;span class="c"&gt;# with a "Prompt coach" section that rewrites it as a proper spec&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So there is no escaping it. I do not need to invoke the coach. The coach invokes itself. If my prompt was clear, nothing happens and I feel nothing. If my prompt was lazy, I get my answer, and then underneath it, a small graded rewrite showing me what I should have typed. Every vague prompt becomes a tiny lesson whether I asked for one or not.&lt;/p&gt;

&lt;p&gt;The log part is the quiet one, but it matters just as much. Every prompt, timestamped, with the folder I was in. The coach has an analyse mode that reads this log and reports whether I am actually improving, with my own prompts quoted back as evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receipts are not flattering
&lt;/h2&gt;

&lt;p&gt;I finally looked at that log before writing this. A few numbers, all from my own file.&lt;/p&gt;

&lt;p&gt;216 prompts collected, and a good number of them very short. Now, a chunk of those short ones are perfectly legitimate. A bare "continue" usually means I hit the usage limit mid-task and resumed the session once the window reset. The coach's own analyse mode knows this and excludes plain continuations from scoring, because they are dialogue, not instructions.&lt;/p&gt;

&lt;p&gt;The interesting ones are the short prompts that were actually carrying a task. "push it and make a PR" is a real instruction with real consequences, and it says nothing about which branch, what the PR description should contain, or whether to open it as a draft. It works because the session already holds that context. It works because the defaults happen to match what I had in my head.&lt;/p&gt;

&lt;p&gt;And that is exactly what the coach keeps circling in red. Not "this failed". It says: this succeeded for a reason you did not control. The day the defaults do not match, a thin prompt does not fail loudly with an error you can read. It quietly succeeds at the wrong thing, and you find out later.&lt;/p&gt;

&lt;p&gt;Count how many of your prompts today would still make sense to a stranger with no session history. That was the number that made me quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually stuck
&lt;/h2&gt;

&lt;p&gt;Here is what I did not expect. The classroom and the hook teach at completely different rates, and it is not the classroom that wins.&lt;/p&gt;

&lt;p&gt;A lesson needs me to stop working, sit down, and answer an exercise properly. That time has to be carved out, and on a normal working day it competes with everything else that is already on fire. The hook needs nothing from me at all. It fires on prompts I was going to type anyway, and when one of them is thin, the answer comes back with that small graded rewrite at the bottom, showing the spec version of what I meant.&lt;/p&gt;

&lt;p&gt;Session after session, project after project, the shape of it starts sticking. I now catch myself mid-prompt thinking "what is the output format here" before hitting enter. Not always. But more than before, and I never scheduled a single minute for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let it read the spec back to you
&lt;/h2&gt;

&lt;p&gt;There is one more habit that lifted the quality of what I get back, and this one needs no skill and no hook at all.&lt;/p&gt;

&lt;p&gt;However carefully I write a spec, it is still a one way message. I send it, work happens, and I find out what the model actually understood by looking at the result. If it read my scope differently than I meant it, I learn that after the files have already changed.&lt;/p&gt;

&lt;p&gt;Plan mode flips the order. The model reads the code, thinks the task through, and then tells me what it intends to do before touching anything. I get to look at that plan and say no, not that file, or you missed the migration, or that is the wrong direction entirely. Only after that does it start work.&lt;/p&gt;

&lt;p&gt;The useful part is where the corrections land. The gap between my spec and its plan is exactly where the invented decisions live. Those five decisions from the start of this post, the ones I never stated and Claude quietly filled in anyway, would have shown up as plain lines in a plan I could read, instead of as surprises in a diff.&lt;/p&gt;

&lt;p&gt;You can start a session in it with &lt;code&gt;--permission-mode plan&lt;/code&gt;, or cycle into it mid session with shift+tab. I do not use it for everything. For a typo fix it is a wasted round trip. But when the task touches a lot of files, or when being wrong means undoing an afternoon of work, reading the plan first costs a minute and saves the whole detour.&lt;/p&gt;

&lt;p&gt;So the two halves fit together nicely. Plan mode catches the wrong assumption before the work happens. The coaching hook tells me afterwards which vague line in my prompt let that assumption in. One saves the task, the other saves the next prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want this for yourself
&lt;/h2&gt;

&lt;p&gt;The pattern is small and very copyable, and none of it is specific to my setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A skill&lt;/strong&gt; holds the knowledge: the curriculum, the rubric, the template. This is the part that knows what good looks like.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hook&lt;/strong&gt; does the enforcement: it fires on every prompt, so the teaching happens inside real work instead of in a separate practice session that has to fight the calendar for a slot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A log&lt;/strong&gt; keeps the evidence: a jsonl file of everything you typed, so "am I improving" becomes a question with an answer instead of a feeling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The honest lesson for me was not really about prompting. It was about where learning has to sit to survive a busy week. A curriculum teaches me when I show up for it. A hook teaches me whether I show up or not, and that difference turns out to be the whole thing.&lt;/p&gt;

&lt;p&gt;So that is where I will stop. If you have built something like this for your own habits, a linter for your behaviour instead of your code, I genuinely want to hear how it went - drop me a note. Otherwise, see you when the next interesting problem shows up.&lt;/p&gt;

</description>
      <category>promptengineering</category>
      <category>claudecode</category>
      <category>hooks</category>
      <category>aiworkflow</category>
    </item>
    <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>
  </channel>
</rss>
