<?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>Forem: Vineeth N Krishnan</title>
    <description>The latest articles on Forem by Vineeth N Krishnan (@vineethnkrishnan).</description>
    <link>https://forem.com/vineethnkrishnan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3779538%2F09ad6e33-4b77-4429-bfe6-07ebb25ed858.png</url>
      <title>Forem: Vineeth N Krishnan</title>
      <link>https://forem.com/vineethnkrishnan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/vineethnkrishnan"/>
    <language>en</language>
    <item>
      <title>I upgraded our 2.5-year-old self-hosted Sentry without losing a single byte</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:34:38 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/i-upgraded-our-25-year-old-self-hosted-sentry-without-losing-a-single-byte-hg5</link>
      <guid>https://forem.com/vineethnkrishnan/i-upgraded-our-25-year-old-self-hosted-sentry-without-losing-a-single-byte-hg5</guid>
      <description>&lt;h1&gt;
  
  
  I upgraded our 2.5-year-old self-hosted Sentry without losing a single byte
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqoatg70v683jj8yxzp8c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqoatg70v683jj8yxzp8c.png" alt="A developer in a black t-shirt at a home office standing desk, MacBook Air connected to a large external monitor showing terminal logs, editorial illustration style." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The server nobody touched
&lt;/h2&gt;

&lt;p&gt;Every team has one. A server that works. A server that has been working for so long that everyone has collectively agreed to never look at it directly, the way you never look directly at the sun, or at the production database during a demo.&lt;/p&gt;

&lt;p&gt;Ours was the Sentry box.&lt;/p&gt;

&lt;p&gt;Self-hosted Sentry, version 23.9.1, installed in October 2023 on a Hetzner machine running Ubuntu 20.04. It collected errors from six applications — three TypeScript backends, a React frontend, a PHP monolith, and one project cryptically named "internal" that I am not going to explain.&lt;/p&gt;

&lt;p&gt;It worked. It had been working. Nobody was going to touch it.&lt;/p&gt;

&lt;p&gt;Then I touched it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crime scene
&lt;/h2&gt;

&lt;p&gt;Before you upgrade anything on a production server, you check &lt;code&gt;free -h&lt;/code&gt;. This is a tradition. It is also, in this case, a jump scare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;              total        used        free      shared  buff/cache   available
Mem:           30Gi        26Gi       454Mi       349Mi       3.5Gi       3.1Gi
Swap:           9Gi         9Gi         0Ki
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. Zero bytes of swap free. Not "low." Not "concerning." Zero. The server had been running like this for — checking uptime — &lt;strong&gt;373 days&lt;/strong&gt;. Over a year with no swap headroom at all, and nobody noticed because nothing had crashed yet. The server was basically doing a continuous trust fall into the OOM killer's arms, and the OOM killer just kept catching it.&lt;/p&gt;

&lt;p&gt;The culprit was MySQL. Not Sentry's MySQL — Sentry uses Postgres. This was the old PHP monolith's MySQL, running on the same machine, calmly eating 41% of RAM (12.7 GB) like it was paying rent.&lt;/p&gt;

&lt;p&gt;On top of that: 59 Docker containers, a monitoring agent (Netdata) consuming 3.6 GB of RAM for the privilege of watching the server struggle, and Docker Compose v2.21.0 — which was about to become a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan: four hops and a runbook
&lt;/h2&gt;

&lt;p&gt;Sentry self-hosted has this concept of &lt;strong&gt;hard stops&lt;/strong&gt; — mandatory version checkpoints you cannot skip. If you are on 23.9.1 and the latest is 26.3.1, you do not just &lt;code&gt;git checkout 26.3.1&lt;/code&gt; and run the installer. You will get a polite error message, or more likely, a database migration that assumes tables exist from a version you never installed.&lt;/p&gt;

&lt;p&gt;The upgrade path looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;23.9.1 → 23.11.0 → 24.8.0 → 25.5.1 → 26.3.1
         (hop 1)    (hop 2)   (hop 3)   (hop 4)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four hops. Each one means: stop Sentry, checkout the version tag, run the installer, wait for database migrations, start Sentry, verify it works, then move on to the next. Skip one, and you are in uncharted territory with your production error tracking.&lt;/p&gt;

&lt;p&gt;I wrote a runbook. The runbook was 1,020 lines. For context, some of my entire side projects have fewer lines than that runbook. It had phase gates, verification blocks, trace templates, and a log format you were supposed to paste into a scratch file on the server.&lt;/p&gt;

&lt;p&gt;Was the runbook overkill? We will find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-flight: making the server survivable
&lt;/h2&gt;

&lt;p&gt;The first problem was RAM. Running database migrations on a server with zero swap is how you get a partially-migrated Postgres database and a very bad afternoon. The mitigation plan was three steps:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step one&lt;/strong&gt;: Stop Netdata. Sorry, monitoring agent. You are consuming 3.6 GB to watch a server I am about to intentionally stress. You can come back later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two&lt;/strong&gt;: Create a temporary 4 GB swap file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 4G /swapfile-temp
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /swapfile-temp
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkswap /swapfile-temp
&lt;span class="nb"&gt;sudo &lt;/span&gt;swapon /swapfile-temp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step three&lt;/strong&gt;: Shrink MySQL's buffer pool from 13 GB to 8 GB. This is the server equivalent of asking your roommate to please move their stuff so you can fit a couch through the door.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysql &lt;span class="nt"&gt;-u&lt;/span&gt; root &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SET GLOBAL innodb_buffer_pool_size = 8589934592;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After all three: 9.8 GB available RAM, 5.1 GB swap free. The server could breathe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backup everything that matters
&lt;/h2&gt;

&lt;p&gt;Before touching Sentry, I backed up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Config files (&lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;sentry.conf.py&lt;/code&gt;, &lt;code&gt;config.yml&lt;/code&gt; with the Slack credentials)&lt;/li&gt;
&lt;li&gt;A full Sentry export (orgs, projects, teams — 278 KB of JSON)&lt;/li&gt;
&lt;li&gt;Seven Docker volumes as tarballs — Postgres alone was &lt;strong&gt;7.6 GB&lt;/strong&gt; compressed&lt;/li&gt;
&lt;li&gt;All six DSN URLs (the connection strings every client app uses)&lt;/li&gt;
&lt;li&gt;A snapshot of every running container&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total backup: around 8 GB. Most of the time was spent waiting for the Postgres tar to finish.&lt;/p&gt;

&lt;p&gt;The DSN recording is the one you absolutely cannot skip. If those change, every client app needs a redeployment. They did not change. But you check anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hops
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hop 1 (23.9.1 → 23.11.0)&lt;/strong&gt;: Under ten minutes, but not without drama. The install script asked me if I wanted to send telemetry data to Sentry (the company). I did not have an environment variable set to skip this, so it just sat there, waiting for a &lt;code&gt;y/n&lt;/code&gt; I was not expecting. A few minutes wasted staring at a frozen terminal before I figured out what it wanted. Lesson learned: &lt;code&gt;REPORT_SELF_HOSTED_ISSUES=0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hop 2 (23.11.0 → 24.8.0)&lt;/strong&gt;: Maybe ten, twelve minutes. This one had a breaking config change — Django's cache backend switched from &lt;code&gt;MemcachedCache&lt;/code&gt; to &lt;code&gt;PyMemcacheCache&lt;/code&gt;. The options API is completely different between the two. If you do not edit &lt;code&gt;sentry.conf.py&lt;/code&gt; before running the installer, things break. I knew about this from the planning phase, so I had the fix ready. The runbook earned its keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Between hop 2 and 3&lt;/strong&gt;: Docker Compose needed upgrading. Sentry 25.5.1 requires Compose v2.32.2 minimum. We had v2.21.0. One &lt;code&gt;curl&lt;/code&gt; and a &lt;code&gt;sudo&lt;/code&gt; later, we had v2.32.4. Quick detour, nothing dramatic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hop 3 (24.8.0 → 25.5.1)&lt;/strong&gt;: Smooth. Under ten minutes. This is the version that introduced the taskbroker architecture (replacing Celery) and PgBouncer for connection pooling, but the transition does not complete until 26.x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hop 4 (25.5.1 → 26.3.1)&lt;/strong&gt;: Another ten-ish minutes, after a false start. The installer introduced a new interactive prompt asking about S3 nodestore migration. In non-interactive mode, &lt;code&gt;read -p&lt;/code&gt; fails and kills the script. Fix: &lt;code&gt;APPLY_AUTOMATIC_CONFIG_UPDATES=1&lt;/code&gt;. This is the kind of thing that makes you wonder why "non-interactive install" is not just a single flag.&lt;/p&gt;

&lt;p&gt;Then the 503.&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;docker compose up -d&lt;/code&gt;, the Sentry URL returned "Service Unavailable." I stared at the screen for what felt like a long time but was probably a couple of minutes, with the specific kind of calm that only appears when you have 8 GB of backups and know exactly where they are. The nginx container was restarting. The web container was healthy. It resolved itself. The runbook said "no action needed — just wait." The runbook was right.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long did it actually take?
&lt;/h2&gt;

&lt;p&gt;The upgrade itself — backups, four hops, blockers, the 503 scare, verification — took maybe two, three hours. The install scripts were about ten minutes each. The migrations I had been dreading ran without a sound. The real time sink was backing up the Postgres volume, which took almost half an hour on its own.&lt;/p&gt;

&lt;p&gt;But that is not the real answer.&lt;/p&gt;

&lt;p&gt;The real answer is that the planning took longer than the execution. I spent over four hours before I touched a single thing on that server. SSH in, document the exact state of everything — RAM, swap, disk, Docker versions, every running container, every volume, every config file. Then figure out the hop path, read every release note between 23.9.1 and 26.3.1, find out which hops have breaking config changes and which need newer Docker Compose. Then write the runbook. Then dry-run it in my head.&lt;/p&gt;

&lt;p&gt;This server had years of error data — the kind you go back to when checking regressions. Team member accounts. Project configs. Slack integrations. Six applications relying on it. If a migration failed halfway through and corrupted the Postgres data, that history is gone. There is no "oops, let me try again." You either have backups that work or you have a very uncomfortable conversation with your team.&lt;/p&gt;

&lt;p&gt;So yes, the upgrade took a morning. But the work that made it take only a morning? That took days.&lt;/p&gt;

&lt;p&gt;No data lost. All six DSNs unchanged. Fifty-nine containers went in, seventy-eight came out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The victory lap that wasn't
&lt;/h2&gt;

&lt;p&gt;Here is where the story should end. Sentry upgraded. All six DSNs intact. Seventy-eight containers healthy. The new version came with the things we actually upgraded for — AI Agent Monitoring with MCP tracing, the new taskbroker architecture, PgBouncer, SeaweedFS nodestore. All of that was working out of the box.&lt;/p&gt;

&lt;p&gt;But then I noticed &lt;strong&gt;Sentry Logs&lt;/strong&gt; in the docs. Structured logging that goes directly into Sentry alongside your errors and traces. No separate ELK stack, no Grafana Loki, just logs flowing into the same place your exceptions already live. This was not part of the upgrade plan — Logs is a separate feature you have to enable manually. But now that I was on 26.3.1, I could.&lt;/p&gt;

&lt;p&gt;The upgrade was done by late morning. By lunch I was already in a new terminal session trying to enable it.&lt;/p&gt;

&lt;p&gt;First thing I did was grep for &lt;code&gt;ourlogs&lt;/code&gt; in the Sentry config. Empty result. The feature flags did not exist.&lt;/p&gt;

&lt;p&gt;Right. The install script from 26.3.1 added the base services but not the feature flags. You need to re-run &lt;code&gt;./install.sh&lt;/code&gt; with the right options for it to inject the &lt;code&gt;ourlogs-*&lt;/code&gt; flags into your config.&lt;/p&gt;

&lt;p&gt;Before re-running the installer, I actually paused and asked myself: "Does &lt;code&gt;./install.sh&lt;/code&gt; clear all my existing data, users, and config?"&lt;/p&gt;

&lt;p&gt;I had run this script four times that morning. But somehow, the fifth time, on a server I had just spent hours carefully upgrading, the thought of running it again made me hesitate. The answer is no, it does not clear your data. I knew this. I asked anyway.&lt;/p&gt;

&lt;p&gt;Re-ran the installer. Grepped again. Ten &lt;code&gt;ourlogs-*&lt;/code&gt; feature flags now present in &lt;code&gt;sentry.conf.py&lt;/code&gt;. The EAP items consumer container was running. The Logs page was available in the UI.&lt;/p&gt;

&lt;p&gt;I clicked on it.&lt;/p&gt;

&lt;p&gt;Empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"I did everything and the logs are not there."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I double-checked the feature flags. All enabled. Checked the containers. All healthy. Checked the Sentry UI. The Logs section was right there, accessible, with a nice empty state saying "no logs found."&lt;/p&gt;

&lt;p&gt;Then the obvious thing hit me. None of my projects actually use the Sentry logger.&lt;/p&gt;

&lt;p&gt;I had enabled the server-side feature. I had not shipped any code that actually sends logs to it. The kitchen was built, the stove was on, and nobody was cooking.&lt;/p&gt;

&lt;p&gt;Enabling Sentry Logs on the server is step one. Step two is updating your application's Sentry SDK to use the new &lt;code&gt;Sentry.logger&lt;/code&gt; API. You need &lt;code&gt;enableLogs: true&lt;/code&gt; in your &lt;code&gt;Sentry.init()&lt;/code&gt;, and a logger that calls &lt;code&gt;Sentry.logger.info()&lt;/code&gt; / &lt;code&gt;Sentry.logger.error()&lt;/code&gt; instead of (or alongside) &lt;code&gt;console.log&lt;/code&gt;. Without that, the Logs page will sit there empty, politely waiting for data that never arrives.&lt;/p&gt;

&lt;p&gt;A colleague had actually written the NestJS integration weeks ago — a &lt;code&gt;SentryLogger&lt;/code&gt; class that extends NestJS's &lt;code&gt;ConsoleLogger&lt;/code&gt; and forwards &lt;code&gt;log&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt;, and &lt;code&gt;fatal&lt;/code&gt; calls to Sentry. It was sitting in a branch. Waiting for the server-side feature to be turned on. Waiting for today.&lt;/p&gt;

&lt;p&gt;We deployed it. The logs appeared. I took a screenshot.&lt;/p&gt;

&lt;p&gt;Minutes later I was taking screenshots of logs flowing in.&lt;/p&gt;

&lt;p&gt;From "I did everything and the logs are not there" to showing it off — that is the developer emotional arc in its purest form. Panic, confusion, realization, deployment, screenshot. In that order. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;For anyone running self-hosted Sentry and considering the same upgrade, here is what went from 23.9.1 to 26.3.1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Celery → taskbroker&lt;/strong&gt;: The worker architecture is completely new. Celery workers are replaced by &lt;code&gt;taskworker&lt;/code&gt; and &lt;code&gt;taskscheduler&lt;/code&gt; containers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PgBouncer&lt;/strong&gt;: PostgreSQL connection pooling is now built in. One less thing to configure yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SeaweedFS nodestore&lt;/strong&gt;: Event data that used to bloat Postgres is now stored in an S3-compatible object store (SeaweedFS). This is the biggest architectural change. Existing data is transparently migrated on read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker registry&lt;/strong&gt;: Images moved from &lt;code&gt;getsentry/&lt;/code&gt; (Docker Hub) to &lt;code&gt;ghcr.io/getsentry/&lt;/code&gt; (GitHub Container Registry).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Agent Monitoring + MCP tracing&lt;/strong&gt;: Track agent runs, tool calls, and MCP server interactions directly in Sentry. This was the main reason we upgraded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container count&lt;/strong&gt;: 59 → 78. Yes, nineteen new containers. Your &lt;code&gt;docker ps&lt;/code&gt; output now needs a wider terminal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry Logs&lt;/strong&gt; (manual enable): Structured logging, directly in Sentry. Not part of the upgrade — you have to enable feature flags and re-run the installer separately. Worth it, once you actually ship the client code to use it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The non-interactive install cheat sheet
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this post, take this. For every Sentry self-hosted version from 23.11.0 through 26.3.1, this is the incantation:&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;REPORT_SELF_HOSTED_ISSUES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;APPLY_AUTOMATIC_CONFIG_UPDATES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
./install.sh &lt;span class="nt"&gt;--skip-user-creation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Saves you from every interactive prompt I hit. Tape it to your monitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was the 1,020-line runbook worth it?
&lt;/h2&gt;

&lt;p&gt;Yes.&lt;/p&gt;

&lt;p&gt;Not because the upgrade was complex — it turned out to be straightforward. But because when the Logs page was empty and I did not know why, and when the URL returned 503 and I did not know how long it would last, having a document that said "this is expected, here is what to check, here is when to worry" was the difference between a methodical next step and a panicked &lt;code&gt;docker compose down&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The runbook was not overkill. The runbook was the reason the upgrade took a morning instead of a weekend.&lt;/p&gt;

&lt;p&gt;And the Astro documentation site I built afterward to record everything? That one might have been overkill. But it does look nice.&lt;/p&gt;

</description>
      <category>sentry</category>
      <category>selfhosted</category>
      <category>devops</category>
      <category>upgrade</category>
    </item>
    <item>
      <title>The day I realised I had never tested a production backup</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:33:57 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/the-day-i-realised-i-had-never-tested-a-production-backup-p68</link>
      <guid>https://forem.com/vineethnkrishnan/the-day-i-realised-i-had-never-tested-a-production-backup-p68</guid>
      <description>&lt;h1&gt;
  
  
  The day I realised I had never tested a production backup
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjvs3qffu043rl5shovho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjvs3qffu043rl5shovho.png" alt="A developer at a wooden desk with a laptop, a row of seven small labelled database icons arranged in front like a compact lab, one lit up with a green success checkmark while a labelled backup file is lowered into it, warm desk lamp glow, flat editorial illustration." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I had run dozens of test-backup restore drills and felt safe. What I had never done was pull a real production snapshot, decrypt it, and bring it up in a sandbox. The day I finally did, it worked — even a big database came back clean. This post is the story, plus the small Docker Compose file I keep on my laptop for exactly this kind of check. No repo, no project, just a compose file. Copy it if you want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cold little question
&lt;/h2&gt;

&lt;p&gt;So there I was, one afternoon, coffee going cold, staring at my own Hetzner Storage Box.&lt;/p&gt;

&lt;p&gt;Neat folders. Neat snapshots. A long green column of audit rows. Every cron had fired, every upload had a size, every week had a tick.&lt;/p&gt;

&lt;p&gt;And a question at the back of my head that I had been avoiding for longer than I want to admit: &lt;em&gt;have I ever actually restored a real one of these?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The honest answer was no. Not properly.&lt;/p&gt;

&lt;p&gt;I want to be fair to myself here, because I had not been careless. Every new engine I added to my backup tool came with its own test drill — spin up a throwaway database, seed it with some rows, take a backup, bring it back up somewhere else, confirm the rows are there. I had run those drills many, many times. Every time I touched the audit layer I reran them. Every time I changed an adapter. I genuinely felt covered.&lt;/p&gt;

&lt;p&gt;But test drills and a real production restore are not the same thing, and part of me knew it. The seed database is five megabytes. The real one is tens of gigabytes, schema-migrated across versions, touched by years of application code, full of columns I had not thought about in a long time. Some classes of problem only show up at that scale. And at some point you have to stop quietly trusting your drills and go pull the real snapshot.&lt;/p&gt;

&lt;p&gt;So that afternoon, I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;I opened the throwaway Docker Compose folder I keep on my laptop exactly for this — I call it &lt;code&gt;backup-verify&lt;/code&gt; because that is what I am doing, nothing fancier than that. Picked the Postgres profile. Pulled the latest production snapshot out of the storage box, decrypted it, dropped the &lt;code&gt;.dump&lt;/code&gt; file into &lt;code&gt;~/Downloads&lt;/code&gt;, and ran &lt;code&gt;pg_restore&lt;/code&gt; from inside the container.&lt;/p&gt;

&lt;p&gt;And it just worked.&lt;/p&gt;

&lt;p&gt;The whole database came back clean. Every schema, every foreign key, every &lt;code&gt;bytea&lt;/code&gt; column, every ancient &lt;code&gt;jsonb&lt;/code&gt; field I had half-expected to be the thing that blew up. I opened pgAdmin, clicked around, pulled up a user I had onboarded earlier that week, checked their orders, checked their uploads, checked the payment rows — all of it, all readable, all in the shape the application expects.&lt;/p&gt;

&lt;p&gt;I sat back for a minute. That was a good minute.&lt;/p&gt;

&lt;p&gt;There is a particular quiet pride in watching a pipeline you built yourself — that you had only ever proven on tiny test data — land an actual production-sized restore on the first try. The drills had been telling me &lt;em&gt;the tool was correct&lt;/em&gt;. This run told me &lt;em&gt;the backups themselves are correct&lt;/em&gt;. Two different claims. I had been conflating them for months. Today they both turned out to be true, but that could very easily have gone the other way, and I would have found out at the worst possible time.&lt;/p&gt;

&lt;p&gt;If you have been putting this off the way I was, tell me I am not the only one.&lt;/p&gt;

&lt;p&gt;Right. Now, the lab I ran it through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compose file
&lt;/h2&gt;

&lt;p&gt;This is not a service. There is no CLI, no scheduler, no diff engine. The whole thing is a single &lt;code&gt;docker-compose.yml&lt;/code&gt;, about 175 lines of it, and a &lt;code&gt;data/&lt;/code&gt; folder it creates as it boots. It lives in one folder on my laptop and has never been a repo. That is it.&lt;/p&gt;

&lt;p&gt;Seven database engines, each behind a Docker Compose profile, each paired with a companion admin UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pgvector/pgvector:pg17&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;verify&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;55432:5432"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data/postgres:/var/lib/postgresql/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;~/Downloads:/dumps:ro&lt;/span&gt;

  &lt;span class="na"&gt;pgadmin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dpage/pgadmin4:latest&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5050:80"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That block, six more times, for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;postgres&lt;/strong&gt; with pgAdmin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mysql&lt;/strong&gt; with phpMyAdmin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mariadb&lt;/strong&gt; with its own phpMyAdmin (yes, separate — MariaDB has its own quirks)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mongo&lt;/strong&gt; with Mongo Express&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redis&lt;/strong&gt; with Redis Commander&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mssql&lt;/strong&gt; with Adminer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;elasticsearch&lt;/strong&gt; with Kibana&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each profile is independent. &lt;code&gt;docker compose --profile postgres up -d&lt;/code&gt; boots only Postgres and pgAdmin. &lt;code&gt;--profile mongo&lt;/code&gt; boots only Mongo and Mongo Express. You never spin up the whole zoo at once, because you almost never need to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two small choices that make me actually use it
&lt;/h2&gt;

&lt;p&gt;Two things in this file sound boring on paper but are the reason I open the folder instead of putting it off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;~/Downloads:/dumps:ro&lt;/code&gt;&lt;/strong&gt; on every service.&lt;/p&gt;

&lt;p&gt;The workflow collapses to this: pull the snapshot out of the storage box, let the &lt;code&gt;.sql&lt;/code&gt; or &lt;code&gt;.dump&lt;/code&gt; or &lt;code&gt;.bson&lt;/code&gt; land in &lt;code&gt;~/Downloads&lt;/code&gt; like any other file, and inside the container it is already sitting at &lt;code&gt;/dumps/myapp.dump&lt;/code&gt;, ready to feed into &lt;code&gt;pg_restore&lt;/code&gt; or &lt;code&gt;mysql&lt;/code&gt; or &lt;code&gt;mongorestore&lt;/code&gt;. No &lt;code&gt;docker cp&lt;/code&gt;. No volume gymnastics. No temp folders. The read-only flag is there so I cannot accidentally scribble junk back into my Downloads folder from inside a container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weird port numbers, on purpose.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres → &lt;code&gt;55432&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;MySQL → &lt;code&gt;33306&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;MariaDB → &lt;code&gt;33307&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Mongo → &lt;code&gt;37017&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Redis → &lt;code&gt;56379&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;MSSQL → &lt;code&gt;11433&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Elasticsearch → &lt;code&gt;19200&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these sits a digit or two away from the default. The reason is petty but load-bearing. I run a real Postgres on &lt;code&gt;5432&lt;/code&gt; for other projects. I do not want the verify lab colliding with it, ever. Ten minutes of picking weird ports on the day I built this have saved me from "wait, which database am I actually connected to right now" more times than I would like to admit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I still refuse to automate the verify step
&lt;/h2&gt;

&lt;p&gt;Every time I show someone this compose file, the suggestion is the same: "Why don't you wrap it in a CLI that does the restore, counts rows, and reports?"&lt;/p&gt;

&lt;p&gt;I have thought about it. I am not going to, and the reason matters.&lt;/p&gt;

&lt;p&gt;The compose file works &lt;em&gt;because&lt;/em&gt; it forces me to open an admin UI and scroll. The moment I replace scrolling with row-count assertions, I will start getting green ticks on the day the backup is silently wrong. A checksum says the bytes match. A row count says the count matches. Neither of those says the &lt;code&gt;orders.status&lt;/code&gt; enum still decodes correctly, or that a &lt;code&gt;bytea&lt;/code&gt; column came back as a string, or that a timestamp came back in UTC when the application wants it in IST. Those are the problems you catch by putting human eyes on the data.&lt;/p&gt;

&lt;p&gt;There is a clean split in this system I want to protect. The &lt;em&gt;making&lt;/em&gt; of backups is automated, scheduled, audited, alerted, and I trust it. The &lt;em&gt;checking&lt;/em&gt; of backups is manual, slow, and not my favourite way to spend a Sunday morning. That is correct. Verification is a ritual, not a job. If I automate it away, I will lose the one thing that actually makes me look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UIs are hilarious in aggregate
&lt;/h2&gt;

&lt;p&gt;A small appreciation, because these tools are a time capsule of twenty years of database tooling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pgAdmin&lt;/strong&gt; is the serious one. Loads slowly, does everything, is the reason I keep using Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;phpMyAdmin&lt;/strong&gt; is twenty years of PHP in a trench coat, and somehow still the fastest way to eyeball a MySQL dump. I do not know how. I have stopped asking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mongo Express&lt;/strong&gt; is sparse but it does its one job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis Commander&lt;/strong&gt; is what I use to remember whether a cached key is JSON or a raw string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adminer&lt;/strong&gt; is the single-file PHP hero of my generation, now driving my MSSQL tab because Microsoft's own tooling refuses to make this easier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kibana&lt;/strong&gt; is absurd overkill for "did the Elasticsearch restore work", but when the only dump you have is an ES snapshot, you use what the ecosystem hands you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of them are pinned to &lt;code&gt;latest&lt;/code&gt;, because this is not production. If any of them break after a version bump, I delete &lt;code&gt;./data&lt;/code&gt; and move on. That is a luxury the rest of my infrastructure does not get.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would still add
&lt;/h2&gt;

&lt;p&gt;Two small items on the list, nothing structural.&lt;/p&gt;

&lt;p&gt;A helper script for Redis, because restoring an &lt;code&gt;.rdb&lt;/code&gt; snapshot is the one place where "drop the file into &lt;code&gt;/dumps&lt;/code&gt; and import it" is not quite enough — you have to stop the container, replace &lt;code&gt;dump.rdb&lt;/code&gt; at the right path, fix permissions, and restart. Every other engine is a one-liner. Redis is the awkward cousin at the dinner table, and I keep re-googling the procedure.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;bacpac&lt;/code&gt; support for MSSQL. The current setup handles &lt;code&gt;.bak&lt;/code&gt; fine, but &lt;code&gt;bacpac&lt;/code&gt; has bitten people I know, and I do not want to be fighting &lt;code&gt;sqlpackage&lt;/code&gt; on the day a client actually needs their data back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go do the boring thing
&lt;/h2&gt;

&lt;p&gt;If you have a backup setup you trust, the honest question is whether "trust" is built on a test drill or on a real restore. If it is the drill, go pull a real snapshot tonight. Not next quarter, not next incident review — tonight. Boot a throwaway database. Put eyes on a table you know by heart. If the rows are where you expect, that is a feeling worth having. If they are not, you have caught it on your terms instead of the worst possible day.&lt;/p&gt;

&lt;p&gt;There is no repo for this one. &lt;code&gt;backup-verify&lt;/code&gt; is just a folder on my laptop with the compose file above in it and a &lt;code&gt;.gitignored&lt;/code&gt; &lt;code&gt;data/&lt;/code&gt; directory it writes to. It is not going to become a published project — the moment it grows a README, someone will file an issue, and then it stops being the thing I can delete without guilt when it breaks. The snippet in this post is the whole thing. Copy it, change the ports that collide with yours, drop the engines you will never restore, and you are done.&lt;/p&gt;

&lt;p&gt;Not going to pretend this was a perfect writeup. But if even one part of it nudges someone to finally open a backup they have been politely ignoring, then it was worth putting down. See you in the next one.&lt;/p&gt;

</description>
      <category>backup</category>
      <category>dockercompose</category>
      <category>database</category>
      <category>ops</category>
    </item>
    <item>
      <title>The second half of shipping a CLI: Homebrew tap, Scoop bucket, and the SHA dance</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:33:46 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/the-second-half-of-shipping-a-cli-homebrew-tap-scoop-bucket-and-the-sha-dance-bmi</link>
      <guid>https://forem.com/vineethnkrishnan/the-second-half-of-shipping-a-cli-homebrew-tap-scoop-bucket-and-the-sha-dance-bmi</guid>
      <description>&lt;h1&gt;
  
  
  The second half of shipping a CLI: Homebrew tap, Scoop bucket, and the SHA dance
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fge2q4idsk5xpapwwl2vb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fge2q4idsk5xpapwwl2vb.png" alt="A tired developer at a desk with two laptops side by side, one showing a Mac terminal with brew install and the other a Windows terminal with scoop install, a cartoon frothy beer mug mascot beside the Mac and a metal ice cream scoop mascot beside the Windows laptop, editorial illustration style." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap nobody mentioned
&lt;/h2&gt;

&lt;p&gt;So you publish a CLI to npm. You put the install line in the README — &lt;code&gt;npm i -g ipwhoami&lt;/code&gt;. You tell a few friends. You move on with your life.&lt;/p&gt;

&lt;p&gt;Then someone on a Mac opens a terminal, reads the README, and thinks, &lt;em&gt;why do I need Node on my machine for a tool that just looks up an IP?&lt;/em&gt; Someone on Windows reads the same line and thinks, &lt;em&gt;what is npm, exactly?&lt;/em&gt; Both of them close the tab. Not because the tool is bad. Because the install line is not the one they are used to.&lt;/p&gt;

&lt;p&gt;This is the part of shipping a CLI that nobody warns you about. Getting the tool working is half the job. Getting it installed the way people on their specific OS actually install things is the other half.&lt;/p&gt;

&lt;p&gt;For Mac, that means &lt;code&gt;brew install&lt;/code&gt;. For Windows, that means &lt;code&gt;scoop install&lt;/code&gt;. Both of them are one line. Both of them look boring from the outside. And setting them up turned out to be a lot more work than I thought it would be — two extra repos, a Ruby file I had never written before, a JSON manifest with PowerShell inside it, and a SHA256 dance that I broke a few times before getting it right.&lt;/p&gt;

&lt;p&gt;This is the packaging side-quest for &lt;code&gt;ipwhoami&lt;/code&gt;, written down honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tap, and the weirdly specific naming rule
&lt;/h2&gt;

&lt;p&gt;The first thing I learned about Homebrew is that it has opinions. Strong ones.&lt;/p&gt;

&lt;p&gt;If you want to publish a package under your own GitHub namespace, the repo holding your formula must be named &lt;code&gt;homebrew-&amp;lt;something&amp;gt;&lt;/code&gt;. Not close to it. Not similar. Exactly that prefix. Because when a user types &lt;code&gt;brew tap vineethkrishnan/ipwhoami&lt;/code&gt;, brew quietly goes looking for &lt;code&gt;github.com/vineethkrishnan/homebrew-ipwhoami&lt;/code&gt;. If the repo is named anything else, nothing happens. No error worth reading. Just a polite failure.&lt;/p&gt;

&lt;p&gt;So I made the repo. One file inside it that matters — &lt;code&gt;Formula/ipwhoami.rb&lt;/code&gt;. Yes, Ruby. A language I have probably written three lines of in my entire career, for a package manager I use every other day without ever looking inside.&lt;/p&gt;

&lt;p&gt;The good news is the formula itself is tiny.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Ipwhoami&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Formula&lt;/span&gt;
  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"IP geolocation lookup from your terminal"&lt;/span&gt;
  &lt;span class="n"&gt;homepage&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/vineethkrishnan/ipwhoami"&lt;/span&gt;
  &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/vineethkrishnan/ipwhoami/archive/refs/tags/ipwhoami-v1.2.1.tar.gz"&lt;/span&gt;
  &lt;span class="n"&gt;sha256&lt;/span&gt; &lt;span class="s2"&gt;"8a85739e0a8ac46a6195241d458a4108a9a2c5f042cde90846719161021852ab"&lt;/span&gt;
  &lt;span class="n"&gt;license&lt;/span&gt; &lt;span class="s2"&gt;"MIT"&lt;/span&gt;

  &lt;span class="n"&gt;depends_on&lt;/span&gt; &lt;span class="s2"&gt;"node"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;=18"&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;install&lt;/span&gt;
    &lt;span class="n"&gt;libexec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"bin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"package.json"&lt;/span&gt;
    &lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;install_symlink&lt;/span&gt; &lt;span class="n"&gt;libexec&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="s2"&gt;"bin/ipwhoami.js"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"ipwhoami"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;assert_match&lt;/span&gt; &lt;span class="s2"&gt;"ipwhoami"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shell_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/ipwhoami --version"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bad news is every one of those lines cost me a read of the docs before I fully understood what it was up to.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;url&lt;/code&gt; is not some release asset or a zip I uploaded somewhere. It is the tarball GitHub generates for you, automatically, for every tag you push. Free CDN, basically. The &lt;code&gt;sha256&lt;/code&gt; is how brew makes sure the file it downloaded is the one I signed off on. One byte off and the install fails loud, which is exactly what you want.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;install&lt;/code&gt; block was the part I spent most time on. You do not want to scatter loose &lt;code&gt;bin/&lt;/code&gt; and &lt;code&gt;src/&lt;/code&gt; folders into &lt;code&gt;/opt/homebrew&lt;/code&gt; like some tragic zip file exploded there. The pattern is — dump everything into &lt;code&gt;libexec&lt;/code&gt; (a sandboxed folder brew gives every package), then create a single symlink into the real &lt;code&gt;bin&lt;/code&gt; so the user has &lt;code&gt;ipwhoami&lt;/code&gt; on their PATH. That is what &lt;code&gt;bin.install_symlink&lt;/code&gt; is doing in one line.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;test do&lt;/code&gt; block is optional, but I liked the idea of it. You run &lt;code&gt;brew test ipwhoami&lt;/code&gt; and brew actually runs the installed binary with &lt;code&gt;--version&lt;/code&gt; to confirm it is not silently broken. Shipping a tiny smoke test with the package felt like a nice thing to do.&lt;/p&gt;

&lt;p&gt;And then you push all this, install it on a clean Mac, watch it pull down Node and then your CLI, and for a second you feel like you have figured this whole thing out. That feeling lasts until you remember you still have to do the same thing for Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoop: same job, thankfully not Ruby
&lt;/h2&gt;

&lt;p&gt;Scoop is Windows' answer to Homebrew. If you have not used it — it is a PowerShell-based package manager, and most of the developers I know on Windows prefer it to Chocolatey because the install model is simpler and does not need admin rights.&lt;/p&gt;

&lt;p&gt;The relief with Scoop is that the manifest is JSON, not Ruby. I know it sounds petty, but after enough rounds of squinting at &lt;code&gt;def install&lt;/code&gt; and wondering if I was supposed to close the block with &lt;code&gt;end&lt;/code&gt;, opening a JSON file felt like coming home.&lt;/p&gt;

&lt;p&gt;The repo is &lt;code&gt;scoop-ipwhoami&lt;/code&gt;. The manifest is &lt;code&gt;ipwhoami.json&lt;/code&gt;. Most of it is the same information as the Homebrew formula wearing a different outfit — same tarball URL, same SHA256 (here it is called &lt;code&gt;hash&lt;/code&gt;), same pointer to Node as a dependency. The only two parts actually worth looking at are the &lt;code&gt;installer&lt;/code&gt; block and the auto-update hooks at the bottom.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"installer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"script"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"New-Item -ItemType Directory -Force -Path &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$dir&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;libexec&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; | Out-Null"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Copy-Item -Recurse &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$dir&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;bin&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$dir&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;libexec&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;bin&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Copy-Item -Recurse &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$dir&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;src&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$dir&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;libexec&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;src&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Copy-Item &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$dir&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;package.json&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$dir&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;libexec&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;package.json&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"checkver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"github"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/vineethkrishnan/ipwhoami"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"autoupdate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/vineethkrishnan/ipwhoami/archive/refs/tags/v$version.tar.gz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"extract_dir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ipwhoami-$version"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installer is a small PowerShell script, because Scoop does not hand you a "symlink my file into bin" helper the way Homebrew does. You write the copying yourself — make a &lt;code&gt;libexec&lt;/code&gt; folder, copy &lt;code&gt;bin&lt;/code&gt;, &lt;code&gt;src&lt;/code&gt;, and &lt;code&gt;package.json&lt;/code&gt; into it. A &lt;code&gt;bin&lt;/code&gt; array elsewhere in the manifest then registers &lt;code&gt;libexec\bin\ipwhoami.js&lt;/code&gt; as a shim called &lt;code&gt;ipwhoami&lt;/code&gt;, which is what puts it on the user's PATH. The mental model is identical to Homebrew, you just do more of it by hand.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;checkver&lt;/code&gt; and &lt;code&gt;autoupdate&lt;/code&gt; are nice touches Homebrew does not really have a clean equivalent of. You tell Scoop, &lt;em&gt;hey, my latest version is whatever the latest GitHub tag says it is&lt;/em&gt;, and Scoop figures out new versions for users automatically. That word — &lt;em&gt;automatically&lt;/em&gt; — matters a lot for what comes next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SHA chicken-and-egg
&lt;/h2&gt;

&lt;p&gt;This is the part that tripped me up the most.&lt;/p&gt;

&lt;p&gt;Both the Homebrew formula and the Scoop manifest need the SHA256 of the release tarball. But the tarball is generated by GitHub only after you push a tag. So the order of operations goes something like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bump the version in &lt;code&gt;package.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Push a tag&lt;/li&gt;
&lt;li&gt;Wait — GitHub generates the tarball&lt;/li&gt;
&lt;li&gt;Download the tarball yourself&lt;/li&gt;
&lt;li&gt;Compute its SHA256&lt;/li&gt;
&lt;li&gt;Paste the SHA into both the formula and the manifest&lt;/li&gt;
&lt;li&gt;Push those to the tap and bucket repos&lt;/li&gt;
&lt;li&gt;Test the install on a real machine and pray&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Skip step 3 and you have nothing to SHA. Guess the SHA and every install fails with a loud mismatch. Copy the SHA wrong — swap a zero with an O, miss a character at the end — every install fails the same way, except now you also do not realise it is &lt;em&gt;you&lt;/em&gt; who broke it until somebody opens an issue.&lt;/p&gt;

&lt;p&gt;I did this whole thing by hand the first time. And yes, I got it wrong. Pasted the SHA from the previous tag, pushed it to the tap, installed on a fresh machine, watched brew scream at me about mismatch. Great. Fine. Fixed it, pushed again, then forgot to run &lt;code&gt;brew update&lt;/code&gt; before re-testing — so brew was still happily using the broken cached formula. I sat there wondering why my fix had not taken effect, until the penny dropped.&lt;/p&gt;

&lt;p&gt;Has this happened to you too? You fix a thing, you are sure you fixed it, and then you spend ages confused because the cache on your own machine is lying to you. The debugging is never the broken thing. It is always something nearby that you had not thought about.&lt;/p&gt;

&lt;p&gt;Anyway, this is the kind of dumb mistake you make exactly once, after which you decide a machine should be doing this job, not you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating the whole thing so I never touch it again
&lt;/h2&gt;

&lt;p&gt;The main &lt;code&gt;ipwhoami&lt;/code&gt; repo uses &lt;a href="https://github.com/googleapis/release-please" rel="noopener noreferrer"&gt;release-please&lt;/a&gt; to handle versions. You write conventional commits, release-please keeps an open PR with the next version and changelog, and merging it cuts a tag. That part I had been using from the start.&lt;/p&gt;

&lt;p&gt;What I added was a set of release-triggered jobs that run after the tag gets cut. The full dance now looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;release-please&lt;/code&gt; opens a release PR. I merge it, a new tag gets pushed.&lt;/li&gt;
&lt;li&gt;The workflow fires. It publishes to npm, builds a multi-arch Docker image, and then — the point of all this — updates the Homebrew tap and the Scoop bucket.&lt;/li&gt;
&lt;li&gt;For each of those, the job sleeps a moment so GitHub can finish generating the tarball, then it curls the tarball, computes the SHA, checks out the tap or bucket repo with a cross-repo PAT, writes a fresh formula or manifest, commits, and pushes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The one step worth looking at is the SHA-compute — the heart of the whole thing the CI is doing for me so I do not have 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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Wait for tarball availability&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sleep &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Compute SHA256&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sha&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;TAG="${{ needs.release-please.outputs.tag_name }}"&lt;/span&gt;
    &lt;span class="s"&gt;URL="https://github.com/vineethkrishnan/ipwhoami/archive/refs/tags/${TAG}.tar.gz"&lt;/span&gt;
    &lt;span class="s"&gt;SHA=$(curl -sL "$URL" | sha256sum | cut -d' ' -f1)&lt;/span&gt;
    &lt;span class="s"&gt;echo "sha256=$SHA" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, the job checks out the tap repo with a cross-repo token, writes a fresh formula with the new URL and SHA baked in, and pushes. The Scoop updater does the same, just writes JSON instead of Ruby.&lt;/p&gt;

&lt;p&gt;One thing that quietly matters here — &lt;code&gt;RELEASE_PAT&lt;/code&gt;. The default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; a workflow gets cannot push to a different repo. For cross-repo commits (the tap and the bucket both live outside the main repo) you need a fine-grained personal access token with &lt;code&gt;contents: write&lt;/code&gt; on those two repos. I missed this on my first run. The job failed at the push step with a 403 I spent longer reading than I will admit.&lt;/p&gt;

&lt;p&gt;Both jobs sleep for ten seconds before curling because GitHub's tarball generation is async — move too fast and you get a 404 or a zero-byte file. The &lt;code&gt;sleep 10&lt;/code&gt; is not elegant. It is honest.&lt;/p&gt;

&lt;p&gt;After all this, my release loop turned into something much nicer. Merge the release PR. Everything downstream updates on its own. npm gets the new version. Docker Hub and GHCR get the new image. Homebrew users see it when they run &lt;code&gt;brew update&lt;/code&gt;. Scoop users see it when they run &lt;code&gt;scoop update&lt;/code&gt;. I do not touch anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;p&gt;If I was starting this whole side-quest over, three things would go differently.&lt;/p&gt;

&lt;p&gt;One — write the release workflow first. Before the formula. Before the manifest. Get the automation scaffolded in a branch, even if the first version just prints the computed SHA to the log and exits. The whole hand-paste-a-hash phase I went through was avoidable. I just did not realise how cheap a GitHub Actions job is until I actually wrote one.&lt;/p&gt;

&lt;p&gt;Two — test the first install on a clean VM or a fresh machine. I lost a good bit of time chasing "why is brew not seeing my changes" before realising my own Mac had a cached formula from my first broken upload. A clean box has no opinions. It shows you exactly what a stranger on the internet would see, which is all that actually matters.&lt;/p&gt;

&lt;p&gt;Three — keep the tap and the bucket each in their own repo, even if it feels like two more repos to manage. Homebrew forces you to. Scoop does not, so I was tempted for a while to dump the Scoop manifest into the main CLI repo and save a repo. Glad I did not. Both of those repos are now boring, stable, and have not needed a single manual commit from me since the automation went in. That is exactly how I want it.&lt;/p&gt;

&lt;p&gt;The install section of the README now reads:&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;# Mac&lt;/span&gt;
brew tap vineethkrishnan/ipwhoami &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; brew &lt;span class="nb"&gt;install &lt;/span&gt;ipwhoami

&lt;span class="c"&gt;# Windows&lt;/span&gt;
scoop bucket add ipwhoami https://github.com/vineethkrishnan/scoop-ipwhoami
scoop &lt;span class="nb"&gt;install &lt;/span&gt;ipwhoami

&lt;span class="c"&gt;# Anywhere&lt;/span&gt;
npm i &lt;span class="nt"&gt;-g&lt;/span&gt; ipwhoami
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines instead of one. Same CLI under the hood. But now Mac and Windows users do not need to know what npm is to use a tool someone built for them. That small thing ended up mattering more than I thought it would.&lt;/p&gt;

&lt;p&gt;That is pretty much the packaging half of ipwhoami. The tap lives at &lt;a href="https://github.com/vineethkrishnan/homebrew-ipwhoami" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/homebrew-ipwhoami&lt;/a&gt;, the bucket at &lt;a href="https://github.com/vineethkrishnan/scoop-ipwhoami" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/scoop-ipwhoami&lt;/a&gt;, and the release workflow driving both is in the main &lt;a href="https://github.com/vineethkrishnan/ipwhoami" rel="noopener noreferrer"&gt;ipwhoami repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Okay, that is enough from me for today. If any of this saved you some time, that is the whole point of writing it down. Until the next one — take it easy.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>homebrew</category>
      <category>scoop</category>
      <category>packaging</category>
    </item>
    <item>
      <title>Setting Up a MinIO CDN with Nginx Reverse Proxy on Docker</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:33:05 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/setting-up-a-minio-cdn-with-nginx-reverse-proxy-on-docker-57kc</link>
      <guid>https://forem.com/vineethnkrishnan/setting-up-a-minio-cdn-with-nginx-reverse-proxy-on-docker-57kc</guid>
      <description>&lt;h1&gt;
  
  
  Setting Up a MinIO CDN with Nginx Reverse Proxy on Docker
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhzojaap65fwy14lqqmzn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhzojaap65fwy14lqqmzn.png" alt="Isometric diagram of a client laptop connecting through an Nginx reverse proxy to a MinIO storage container, arrows showing HTTPS flow, technical illustration in blue and orange." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; - You can run your own S3-compatible CDN on a single small server with MinIO, Nginx, and a free Let's Encrypt cert. The setup is straightforward. The only places that usually bite people are the Host header, body size limits, and buffering. Get those three right and the whole thing just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother self-hosting a CDN?
&lt;/h2&gt;

&lt;p&gt;AWS S3 plus CloudFront is great when someone else is paying the bill. For side projects, staging environments, or small production apps where you already have a VPS sitting around, a MinIO instance behind Nginx gives you the same S3 API with your own SSL, your own data, and a bill that does not surprise you at the end of the month. You also get full control - no region lock-in, no egress fees that scale faster than your traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;Three boxes, one arrow each way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client&lt;/strong&gt; makes a request to &lt;code&gt;cdn.example.com&lt;/code&gt; over HTTPS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx&lt;/strong&gt; (port 443, SSL) terminates TLS and forwards the request&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MinIO container&lt;/strong&gt; (port 9000, localhost only) does the actual storage work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MinIO is never exposed to the public internet directly. Nginx is the only thing the outside world sees. We will point a subdomain like &lt;code&gt;cdn.example.com&lt;/code&gt; at this whole setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An Ubuntu or Debian server (anything recent, 22.04 LTS is fine)&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose installed&lt;/li&gt;
&lt;li&gt;A domain with a DNS A record pointing to your server's IP&lt;/li&gt;
&lt;li&gt;Ports 80 and 443 open on your firewall&lt;/li&gt;
&lt;li&gt;Some idea of what a reverse proxy does (since you are here, I will assume yes)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Right. Let us get into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Run MinIO with Docker Compose
&lt;/h2&gt;

&lt;p&gt;Create a folder somewhere sensible - I usually go with &lt;code&gt;/opt/minio&lt;/code&gt; - and drop this in as &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;minio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio/minio:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server /data --console-address ":9001"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ROOT_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_ROOT_USER}&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_ROOT_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_SERVER_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://cdn.example.com&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_BROWSER_REDIRECT_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://cdn.example.com/console&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:9000:9000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:9001:9001"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:9000/minio/health/live"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth calling out here.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;127.0.0.1:&lt;/code&gt; prefix on the port mapping is the important bit. Without it, Docker binds to &lt;code&gt;0.0.0.0&lt;/code&gt; and MinIO ends up open to the whole internet. With it, only processes on the host machine can talk to those ports - which is exactly what we want, since Nginx is going to be the front door.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;MINIO_SERVER_URL&lt;/code&gt; tells MinIO what its public URL is. This matters for presigned URLs - MinIO needs to know what hostname to sign against, otherwise you get signature mismatches when clients try to use the URL.&lt;/p&gt;

&lt;p&gt;The healthcheck is basic but enough. MinIO has a built-in liveness endpoint, and Docker will mark the container unhealthy if it stops responding.&lt;/p&gt;

&lt;p&gt;Drop your credentials in a &lt;code&gt;.env&lt;/code&gt; file next to the compose file:&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;MINIO_ROOT_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin
&lt;span class="nv"&gt;MINIO_ROOT_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;please-change-this-to-something-long-and-random
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bring it up:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;You should see MinIO reporting healthy. If you &lt;code&gt;curl http://127.0.0.1:9000/minio/health/live&lt;/code&gt; from the host, you should get a 200. So far so good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Configure Nginx as a reverse proxy
&lt;/h2&gt;

&lt;p&gt;This is the part where most tutorials hand-wave. Do not skip the details here - the defaults are wrong for MinIO in a few specific ways.&lt;/p&gt;

&lt;p&gt;Drop this into &lt;code&gt;/etc/nginx/sites-available/cdn.example.com&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Redirect plain HTTP to HTTPS&lt;/span&gt;
&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="s"&gt;[::]:80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;cdn.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://&lt;/span&gt;&lt;span class="nv"&gt;$host$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# The main SSL block&lt;/span&gt;
&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="s"&gt;[::]:443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;cdn.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Certbot will fill these in for you in Step 3&lt;/span&gt;
    &lt;span class="c1"&gt;# ssl_certificate     /etc/letsencrypt/live/cdn.example.com/fullchain.pem;&lt;/span&gt;
    &lt;span class="c1"&gt;# ssl_certificate_key /etc/letsencrypt/live/cdn.example.com/privkey.pem;&lt;/span&gt;

    &lt;span class="c1"&gt;# Allow large uploads&lt;/span&gt;
    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;5G&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Do not buffer - MinIO streams, and buffering breaks large transfers&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_request_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Stop Nginx from chunking responses that are already fine&lt;/span&gt;
    &lt;span class="kn"&gt;chunked_transfer_encoding&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Timeouts that make sense for big files&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;send_timeout&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:9000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# These headers are the difference between working and "SignatureDoesNotMatch"&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&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;Now the why, because this is where I lost time the first time I did this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;client_max_body_size 5G&lt;/code&gt;&lt;/strong&gt; - Nginx defaults to 1MB. One megabyte. If anyone tries to upload a 20MB image, Nginx rejects it before MinIO even sees the request, and the error message is unhelpful. Set this to whatever your biggest expected file is, plus some headroom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;proxy_buffering off&lt;/code&gt; and &lt;code&gt;proxy_request_buffering off&lt;/code&gt;&lt;/strong&gt; - by default Nginx buffers the whole request before passing it upstream. For a multi-gigabyte upload, that means Nginx writes the file to its own temp folder first, then sends it to MinIO. You pay twice in disk IO and you might run out of &lt;code&gt;/tmp&lt;/code&gt; space. Turning buffering off makes Nginx stream the request straight through, which is how S3-compatible clients expect things to work anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;proxy_set_header Host $host&lt;/code&gt;&lt;/strong&gt; - this is the one everyone forgets. S3 signatures are computed over a canonical request that includes the Host header. If the client signed the request against &lt;code&gt;cdn.example.com&lt;/code&gt; but Nginx forwards it with &lt;code&gt;Host: 127.0.0.1:9000&lt;/code&gt;, the signature MinIO computes will not match the one the client sent, and you will get &lt;code&gt;SignatureDoesNotMatch&lt;/code&gt; errors that make no sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;chunked_transfer_encoding off&lt;/code&gt;&lt;/strong&gt; - MinIO already handles its own transfer encoding. Letting Nginx add another layer on top causes intermittent breakage with larger files, especially when clients use multipart uploads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts&lt;/strong&gt; - default Nginx timeouts are measured for serving HTML, not for pushing gigabyte files around. Bump them up.&lt;/p&gt;

&lt;p&gt;Enable the site and reload Nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/nginx/sites-available/cdn.example.com /etc/nginx/sites-enabled/
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have been down the "why is my presigned URL failing" rabbit hole before, you know the pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Get an SSL certificate with Certbot
&lt;/h2&gt;

&lt;p&gt;Let's Encrypt makes this painless. Install Certbot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; certbot python3-certbot-nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then issue the cert. The &lt;code&gt;--nginx&lt;/code&gt; plugin will edit your Nginx config automatically and uncomment the SSL lines we left commented out above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; cdn.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Follow the prompts, say yes to the HTTPS redirect (we already have one, but Certbot is smart enough to not duplicate). Certbot sets up a systemd timer for auto-renewal. Check it works with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot renew &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that comes back clean, you are set - renewal will happen on its own every 60 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Create a bucket and test
&lt;/h2&gt;

&lt;p&gt;The MinIO console runs on port 9001, but we bound it to localhost. So you have two ways to reach it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A - SSH tunnel:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 9001:127.0.0.1:9001 you@your-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://localhost:9001&lt;/code&gt; in your browser. Log in with the root credentials from your &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B - separate subdomain&lt;/strong&gt; like &lt;code&gt;console.cdn.example.com&lt;/code&gt; with its own Nginx block pointing to &lt;code&gt;127.0.0.1:9001&lt;/code&gt;. Do this if you access the console often. For a one-off bucket setup the tunnel is fine.&lt;/p&gt;

&lt;p&gt;Inside the console, create a bucket - call it &lt;code&gt;public-assets&lt;/code&gt; for this example. Then go to the bucket's Anonymous Access tab and add a rule allowing &lt;code&gt;GetObject&lt;/code&gt; on &lt;code&gt;public-assets/*&lt;/code&gt; for anonymous users. That makes it a public read bucket.&lt;/p&gt;

&lt;p&gt;You can also do this from the &lt;code&gt;mc&lt;/code&gt; CLI, which is usually faster:&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;# Install mc (MinIO client)&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;minio/stable/mc   &lt;span class="c"&gt;# or the appropriate install for your OS&lt;/span&gt;

&lt;span class="c"&gt;# Configure it to point at your CDN&lt;/span&gt;
mc &lt;span class="nb"&gt;alias set &lt;/span&gt;cdn https://cdn.example.com admin your-password

&lt;span class="c"&gt;# Make a bucket and set it public&lt;/span&gt;
mc mb cdn/public-assets
mc anonymous &lt;span class="nb"&gt;set &lt;/span&gt;download cdn/public-assets

&lt;span class="c"&gt;# Upload a test file&lt;/span&gt;
mc &lt;span class="nb"&gt;cp&lt;/span&gt; ~/Downloads/test.jpg cdn/public-assets/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now the moment of truth:&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;-I&lt;/span&gt; https://cdn.example.com/public-assets/test.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should get &lt;code&gt;HTTP/2 200&lt;/code&gt; back with proper content-type headers. If you do, congratulations - you have a working CDN.&lt;/p&gt;

&lt;p&gt;If you do not, jump to the gotchas section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Using it as a CDN from your app
&lt;/h2&gt;

&lt;p&gt;Here is what actually using this looks like from a Node.js app. Install the AWS SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a minimal setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GetObjectCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-s3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getSignedUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/s3-request-presigner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// point the SDK at your MinIO&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cdn.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// MinIO ignores this but the SDK insists&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MINIO_ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MINIO_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;forcePathStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// important - MinIO uses path-style, not virtual-host style&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// upload a file&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uploadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&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;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public-assets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ContentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`https://cdn.example.com/public-assets/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// generate a short-lived URL the browser can upload to directly&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getUploadUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;contentType&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="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public-assets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ContentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&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;await&lt;/span&gt; &lt;span class="nf"&gt;getSignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s3&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expiresIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// valid for 15 minutes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;forcePathStyle: true&lt;/code&gt; line is non-negotiable. MinIO serves buckets at &lt;code&gt;/bucket-name/&lt;/code&gt; paths, not as &lt;code&gt;bucket-name.cdn.example.com&lt;/code&gt; subdomains. Leave that off and everything breaks in weird ways.&lt;/p&gt;

&lt;p&gt;Presigned URLs are the pattern you want for browser uploads. The server signs the URL, hands it to the client, and the browser uploads directly to MinIO without the file ever passing through your app server. Saves you bandwidth and a lot of memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;p&gt;I have hit all of these at least once. Saving you the time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SignatureDoesNotMatch&lt;/code&gt; on presigned URLs.&lt;/strong&gt; Ninety percent of the time this is the Host header. Make sure Nginx is forwarding &lt;code&gt;Host: cdn.example.com&lt;/code&gt; and that &lt;code&gt;MINIO_SERVER_URL&lt;/code&gt; in the compose file matches exactly. Mismatch between what the client signs and what MinIO computes equals failed signatures, every single time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Large uploads failing at around 1MB or 10MB.&lt;/strong&gt; That is Nginx's &lt;code&gt;client_max_body_size&lt;/code&gt;. Bump it up. If uploads fail at bigger sizes (say 100MB+), check &lt;code&gt;proxy_request_buffering&lt;/code&gt; is off - without that, Nginx buffers the whole thing and may run out of disk or hit &lt;code&gt;proxy_max_temp_file_size&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser uploads failing with CORS errors.&lt;/strong&gt; MinIO does not send CORS headers by default. Set them on the bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mc anonymous &lt;span class="nb"&gt;set &lt;/span&gt;download cdn/public-assets
mc cors &lt;span class="nb"&gt;set &lt;/span&gt;cdn/public-assets cors.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;cors.json&lt;/code&gt; looks something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"CORSRules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AllowedOrigins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://your-app.com"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AllowedMethods"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AllowedHeaders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ExposeHeaders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ETag"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"MaxAgeSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Do not expose port 9000 to the public.&lt;/strong&gt; I mean it. MinIO on its own, without TLS, without a firewall, is not something you want on the open internet. Someone finds the admin password, your bucket is their bucket now. The whole reason we bound it to &lt;code&gt;127.0.0.1&lt;/code&gt; in the compose file is to force all traffic through Nginx, which is the layer that actually has TLS, rate limiting, and logging. Keep it that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;This setup is solid for staging and small-to-medium production workloads - I have run it under real traffic without drama. If you are pushing heavy traffic, put Cloudflare in front of Nginx for the edge caching, or look at MinIO's distributed mode across multiple nodes. For most of us though, a single box with Nginx and MinIO is more than enough and costs roughly what a decent lunch does per month.&lt;/p&gt;

&lt;p&gt;So that is where I will stop. If you have a different way of doing this, or hit a gotcha I missed, 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>minio</category>
      <category>nginx</category>
      <category>docker</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>My .zshrc is 350 lines and I mass-replaced every core Unix command</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:32:53 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/my-zshrc-is-350-lines-and-i-mass-replaced-every-core-unix-command-2bn8</link>
      <guid>https://forem.com/vineethnkrishnan/my-zshrc-is-350-lines-and-i-mass-replaced-every-core-unix-command-2bn8</guid>
      <description>&lt;h1&gt;
  
  
  My .zshrc is 350 lines and I mass-replaced every core Unix command
&lt;/h1&gt;

&lt;p&gt;Every developer has a dirty secret. Mine lives in two files.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.zshrc&lt;/code&gt; — 350 lines. Organized, sectioned, commented like production code. The refined version. The tuxedo.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.bash_aliases&lt;/code&gt; — 68 lines. No sections. No comments. A hardcoded password on line 48. A meeting reminder with a typo I never fixed. The digital equivalent of a shoebox full of receipts from 2016.&lt;/p&gt;

&lt;p&gt;Together, they are over 400 lines of shell configuration that I have carried — like a suitcase I refuse to unpack — across operating systems, machines, jobs, and an entire decade of my career.&lt;/p&gt;

&lt;p&gt;This is the archaeology of my dotfiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The secret gist
&lt;/h2&gt;

&lt;p&gt;Before we dig in, let me explain the preservation method.&lt;/p&gt;

&lt;p&gt;Years ago, I was about to move to a new machine. The thought of leaving my config behind felt like abandoning a pet at a rest stop. Physical devices are a burden to carry around. Hard drives fail. Laptops get replaced. But a GitHub gist? A gist is forever.&lt;/p&gt;

&lt;p&gt;So I created a secret gist. Pasted in my &lt;code&gt;.bashrc&lt;/code&gt;, my &lt;code&gt;.bash_aliases&lt;/code&gt;, and a handful of other snippets I'd collected — the kind of things a developer hoards "just in case." That gist is still there. Still secret. Still the first thing I pull on any new machine, before I even open a code editor.&lt;/p&gt;

&lt;p&gt;New machine ritual:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the OS&lt;/li&gt;
&lt;li&gt;Pull the gist&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Then&lt;/em&gt; start working&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else is replaceable. The config is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bash_aliases: a time capsule you can source
&lt;/h2&gt;

&lt;p&gt;Here's the thing about my &lt;code&gt;.bash_aliases&lt;/code&gt; file — it still gets loaded. Every single time I open a terminal in 2026, this line runs:&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="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.bash_aliases &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; ~/.bash_aliases
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line has survived the migration from bash to zsh, from Linux to macOS, from PHP to TypeScript. It's the most resilient piece of code I've ever written, and it's not even code — it's a conditional file include.&lt;/p&gt;

&lt;p&gt;And what does it load? Sixty-eight lines of a Linux PHP developer's entire worldview, frozen in amber.&lt;/p&gt;

&lt;h3&gt;
  
  
  The aliases that tell my whole career story
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;html&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"cd /var/www/html/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you know, you know. This is the alias of someone who typed &lt;code&gt;cd /var/www/html/&lt;/code&gt; forty times a day and finally snapped. Classic LAMP stack energy. I haven't had a &lt;code&gt;/var/www/html/&lt;/code&gt; directory in years, but the alias stays. It's heritage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;art&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"php artisan"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Laravel era. Two characters instead of eleven. At my peak, I was running &lt;code&gt;art migrate&lt;/code&gt;, &lt;code&gt;art serve&lt;/code&gt;, &lt;code&gt;art make:controller&lt;/code&gt; so often that &lt;code&gt;php artisan&lt;/code&gt; felt like typing a formal letter when a text would do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;pst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sh ~/PhpStorm-172.3198.4/bin/phpstorm.sh"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one sends me. I was launching PhpStorm from a &lt;em&gt;shell script&lt;/em&gt; with a &lt;em&gt;hardcoded version path&lt;/em&gt;. Not from a desktop shortcut. Not from a &lt;code&gt;/usr/local/bin&lt;/code&gt; symlink. From &lt;code&gt;~/PhpStorm-172.3198.4/bin/phpstorm.sh&lt;/code&gt;. Like a gentleman.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;screenup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"xrandr --output eDP1 --rotate inverted"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;screendown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"xrandr --output eDP1 --rotate normal"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had a Linux laptop where I needed to flip the screen. I don't remember why. I don't want to remember why. But I aliased it, because apparently I was doing it often enough to justify two entries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;open&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"xdg-open"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a Linux developer making their terminal feel like macOS. On Linux, &lt;code&gt;open&lt;/code&gt; doesn't exist — you need &lt;code&gt;xdg-open&lt;/code&gt;. So I aliased it. Years later, I moved to macOS where &lt;code&gt;open&lt;/code&gt; actually works natively. The alias became unnecessary. It stayed anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ones I probably shouldn't show you
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;sql_mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"echo '********' | sudo -S ~/./set-sql-mode.sh"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;stop_server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"echo '********' | sudo -S ~/./stop_server.sh"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;start_server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"echo '********' | sudo -S ~/./start_server.sh"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes. That's a plaintext password. In a shell alias. Piped into &lt;code&gt;sudo&lt;/code&gt;. I was young. I was reckless. I was tired of typing my password three times to restart Apache. Don't do this. I did this. We've all done this.&lt;/p&gt;

&lt;h3&gt;
  
  
  The typo that will outlive me
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;weekly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'notify-send -t 5000 --icon=clock "Knock Kncok" "Agency weekly is about to start"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Knock Kncok." Not "Knock Knock." &lt;em&gt;Kncok.&lt;/em&gt; This alias sent me a desktop notification before my weekly agency meeting, and for however long I used it, it greeted me with a typo. I never fixed it. It's still there. It will remain there until the heat death of the universe or until GitHub deletes my gist, whichever comes first.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Shopware chapter
&lt;/h3&gt;

&lt;p&gt;A solid chunk of the file is Shopware plugin development aliases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;bcl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bin/console cache:clear"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;pcr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bin/console plugin:create"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;pin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bin/console plugin:install --reinstall -c --activate"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;pact&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bin/console plugin:activate -c"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;pli&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bin/console plugin:list"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten aliases for one framework's CLI. That's not configuration, that's muscle memory committed to disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The .zshrc: the evolved form
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;.bash_aliases&lt;/code&gt; is the archaeological dig, &lt;code&gt;.zshrc&lt;/code&gt; is the modern city built on top of it. Three hundred and fifty lines. Organized into sections with dividers. Comments that explain things. The version of my config that I wouldn't be embarrassed to show in a blog post. So here we are.&lt;/p&gt;

&lt;h3&gt;
  
  
  I replaced every core command
&lt;/h3&gt;

&lt;p&gt;At some point, I decided that the standard Unix tools weren't good enough. So I replaced them. All of them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias cat&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'bat --paging=never'&lt;/span&gt;
&lt;span class="nb"&gt;alias grep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'rg'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;find&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'fd'&lt;/span&gt;
&lt;span class="nb"&gt;alias cd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'z'&lt;/span&gt;
&lt;span class="nb"&gt;alias ls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'eza --icons --group-directories-first'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cat&lt;/code&gt; is now &lt;code&gt;bat&lt;/code&gt;. &lt;code&gt;grep&lt;/code&gt; is now &lt;code&gt;ripgrep&lt;/code&gt;. &lt;code&gt;find&lt;/code&gt; is now &lt;code&gt;fd&lt;/code&gt;. &lt;code&gt;cd&lt;/code&gt; is now &lt;code&gt;zoxide&lt;/code&gt;. &lt;code&gt;ls&lt;/code&gt; is now &lt;code&gt;eza&lt;/code&gt; with icons and git status.&lt;/p&gt;

&lt;p&gt;I have effectively replaced the core Unix experience. If you SSH'd into my machine and tried to use it, nothing would behave the way you expect. &lt;code&gt;cat&lt;/code&gt; has syntax highlighting. &lt;code&gt;ls&lt;/code&gt; has icons. &lt;code&gt;cd&lt;/code&gt; remembers where you've been. It's not a terminal anymore — it's &lt;em&gt;my&lt;/em&gt; terminal.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fzf setup that took longer than some of my projects
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;FZF_DEFAULT_OPTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'
  --height 40%
  --layout=reverse
  --border rounded
  --preview "bat --style=numbers --color=always --line-range=:200 {}"
  --color=bg+:#1a1b26,bg:#12131b,spinner:#8a96fd,hl:#f7768e
  --color=fg:#d5dbe8,header:#66ccf0,info:#e0c07b,pointer:#8a96fd
  --color=marker:#66da8e,fg+:#d5dbe8,prompt:#8a96fd,hl+:#f7768e
'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a custom color scheme. For a fuzzy finder. I color-matched my file search tool to my terminal theme. This is what happens when a developer has opinions about aesthetics and access to hex codes.&lt;/p&gt;

&lt;p&gt;But the real power is in the functions built on top of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Search file contents, preview with syntax highlighting, open in editor&lt;/span&gt;
&lt;span class="k"&gt;function &lt;/span&gt;fzg&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;file line
  &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; file line &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;rg &lt;span class="nt"&gt;--line-number&lt;/span&gt; &lt;span class="nt"&gt;--no-heading&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
    fzf &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="nt"&gt;--preview&lt;/span&gt; &lt;span class="s1"&gt;'bat --style=numbers --color=always \
    --highlight-line {2} {1}'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'{print $1, $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EDITOR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;vim&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="s2"&gt;"+&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type &lt;code&gt;fzg "TODO"&lt;/code&gt;, and it ripgreps your entire project, shows results in a fuzzy finder with a syntax-highlighted preview pane, and opens the file at the exact line in your editor. Three tools chained together into one command. This is the kind of thing that makes you feel like a wizard the first time it works and completely unemployable on anyone else's machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The extract function everyone has
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;function &lt;/span&gt;extract&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
      &lt;span class="k"&gt;*&lt;/span&gt;.tar.bz2&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="nb"&gt;tar &lt;/span&gt;xjf &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;     &lt;span class="p"&gt;;;&lt;/span&gt;
      &lt;span class="k"&gt;*&lt;/span&gt;.tar.gz&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="nb"&gt;tar &lt;/span&gt;xzf &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;     &lt;span class="p"&gt;;;&lt;/span&gt;
      &lt;span class="k"&gt;*&lt;/span&gt;.zip&lt;span class="p"&gt;)&lt;/span&gt;       unzip &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;       &lt;span class="p"&gt;;;&lt;/span&gt;
      &lt;span class="k"&gt;*&lt;/span&gt;.7z&lt;span class="p"&gt;)&lt;/span&gt;        7z x &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;        &lt;span class="p"&gt;;;&lt;/span&gt;
      &lt;span class="c"&gt;# ... six more formats&lt;/span&gt;
    &lt;span class="k"&gt;esac&lt;/span&gt;
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I am convinced that every developer who has maintained a dotfile for more than two years has a version of this function. Nobody memorizes tar flags. Nobody ever will. We all just write this function once and carry it forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  The little things that add up
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;weather&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'curl -s "wttr.in?format=3"'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;myip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'echo "Local: $(ipconfig getifaddr en0)" &amp;amp;&amp;amp; echo "Public: $(curl -s ifconfig.me)"'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;killport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'function _killport(){ lsof -ti:$1 | xargs kill -9; }; _killport'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;duh&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'du -sh * | sort -rh | head -20'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Weather in the terminal. IP address in one command. Kill whatever's hogging a port. See what's eating disk space. None of these are impressive on their own. Together, they're the reason I can't use anyone else's terminal without getting frustrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that bridges two eras
&lt;/h2&gt;

&lt;p&gt;If I had to pick one line that captures this entire story, it's this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.bash_aliases &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; ~/.bash_aliases
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A zsh config file, on a MacBook, in 2026, loading a bash aliases file that contains &lt;code&gt;cd /var/www/html/&lt;/code&gt; and a hardcoded password from a Linux machine that no longer exists.&lt;/p&gt;

&lt;p&gt;That's not configuration. That's identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;I've been a developer since 2012. I've changed languages, frameworks, operating systems, editors, jobs, and countries. Through all of it, the one constant has been a shell config that grew with me — from a handful of PHP shortcuts on a Linux laptop to a 350-line productivity setup on a MacBook.&lt;/p&gt;

&lt;p&gt;Is it excessive? Probably. Is every line necessary? Definitely not. Could I start fresh with a clean &lt;code&gt;.zshrc&lt;/code&gt;? Technically.&lt;/p&gt;

&lt;p&gt;But I won't. Because every alias is a bookmark in my career. &lt;code&gt;art&lt;/code&gt; is the Laravel years. &lt;code&gt;html&lt;/code&gt; is the LAMP stack era. &lt;code&gt;pst&lt;/code&gt; is the kid who launched his IDE from a shell script. And somewhere in a secret gist, there's a meeting reminder that still says "Knock Kncok."&lt;/p&gt;

&lt;p&gt;I wouldn't change a character.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dotfiles:&lt;/strong&gt; Some things are too personal to share. This was one of them. Until now.&lt;/p&gt;

</description>
      <category>shell</category>
      <category>zsh</category>
      <category>dotfiles</category>
      <category>devtools</category>
    </item>
    <item>
      <title>My family thinks WhatsApp can send anything. So I wrote a Python CLI.</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:32:12 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/my-family-thinks-whatsapp-can-send-anything-so-i-wrote-a-python-cli-27na</link>
      <guid>https://forem.com/vineethnkrishnan/my-family-thinks-whatsapp-can-send-anything-so-i-wrote-a-python-cli-27na</guid>
      <description>&lt;h1&gt;
  
  
  My family thinks WhatsApp can send anything. So I wrote a Python CLI.
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8owl9mkz5q21g69twvkg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8owl9mkz5q21g69twvkg.png" alt="An overwhelmed developer in a grey hoodie at a laptop, three giant dark disc-shaped boulders floating menacingly above him, representing three DVD files bearing down." width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The weekend plot twist
&lt;/h2&gt;

&lt;p&gt;It was a normal weekend. My older cousin was visiting. Lunch was done, tea was going, the mood was relaxed. I had one of those rare afternoons where nobody was asking me to "just fix one small thing" on their laptop.&lt;/p&gt;

&lt;p&gt;And then someone in the room brought up the wedding video.&lt;/p&gt;

&lt;p&gt;Not mine. My cousin's wedding. From &lt;strong&gt;2005&lt;/strong&gt;. Ripped from a DVD years ago. Sitting on some old hard drive I kept because apparently I am the family digital archivist now — a role I never applied for.&lt;/p&gt;

&lt;p&gt;Another cousin was suddenly excited.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Cousin:&lt;/strong&gt; Hey, can you send that wedding video to the family group?&lt;br&gt;
&lt;strong&gt;Me:&lt;/strong&gt; You mean the full video?&lt;br&gt;
&lt;strong&gt;Cousin:&lt;/strong&gt; Yeah yeah, everyone wants to see it. Nostalgia and all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Innocent enough. Family group, nostalgia, share the video. Five-minute task. Except.&lt;/p&gt;

&lt;h2&gt;
  
  
  The .VOB situation
&lt;/h2&gt;

&lt;p&gt;The file was in &lt;strong&gt;.VOB format&lt;/strong&gt;. Three parts. Each one around 2 to 3 GB. Total: roughly 8 GB of early 2000s DVD goodness.&lt;/p&gt;

&lt;p&gt;If you are under thirty, &lt;code&gt;.VOB&lt;/code&gt; might not mean much to you. It is the container format DVD players used, back when DVDs were a thing people rented from shops. Old, not streaming friendly, and most modern phones look at it the same way your cat looks at a cucumber.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1gtvwagg261jxrkefgge.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1gtvwagg261jxrkefgge.png" alt="A confused cartoon smartphone with wide eyes standing next to a mysterious chained scroll covered in cryptic film reel symbols, representing a modern phone trying to open a .VOB file." width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Modern phone stock video player meets a 2005 &lt;code&gt;.VOB&lt;/code&gt; file:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Stock Video Player:&lt;/strong&gt; Sorry, this file format is not supported.&lt;br&gt;
&lt;strong&gt;Me:&lt;/strong&gt; Of course it is not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So step one was already impossible. Even if WhatsApp somehow let me upload 8 GB, nobody in the family would be able to play the thing on their phone anyway. WhatsApp also has a file size limit that is not 8 GB. It is not even close to 8 GB. You can google the exact number, but the point is — my 2005 wedding archive was not going through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Family tech support mode
&lt;/h2&gt;

&lt;p&gt;My family group is the classic Indian family group. Some very young, some very old, most somewhere in the middle. The group has a very specific worldview which I will call &lt;strong&gt;WhatsApp University syndrome&lt;/strong&gt;: whatever it is, someone will try to send it through WhatsApp.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 40-page PDF? Send it in the group.&lt;/li&gt;
&lt;li&gt;A 20 MB meme image? Group.&lt;/li&gt;
&lt;li&gt;A video of the dog? Group.&lt;/li&gt;
&lt;li&gt;An 8 GB three-part DVD rip from 2005? &lt;em&gt;Also group.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sending files to my family through WhatsApp is never a technical question. It is a &lt;strong&gt;faith&lt;/strong&gt; question. They believe WhatsApp will handle it, the way previous generations believed the postman would definitely find the house just from a vague description like "opposite the old banyan tree, near the temple."&lt;/p&gt;

&lt;p&gt;So when I said "the video is too big and in the wrong format, we can't send it through WhatsApp," what they actually heard was "I have personally failed the family."&lt;/p&gt;

&lt;p&gt;Cousin offered the backup plan.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Cousin:&lt;/strong&gt; Then just upload it to Google Drive no?&lt;br&gt;
&lt;strong&gt;Me:&lt;/strong&gt; Uhh.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why I did not just use Google Drive
&lt;/h2&gt;

&lt;p&gt;Look. Technically, Google Drive would have solved this. Upload the file, share the link, anyone in the family opens the link, Drive plays the video in the browser. Done. Move on.&lt;/p&gt;

&lt;p&gt;But there is a small, very personal thing I have with giving Google one more file about my family. Google already knows an uncomfortable amount about me. My location history. My search history. My email. My calendar. My photos. Probably my blood type.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F046fs9lrsfe3nsekbmeo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F046fs9lrsfe3nsekbmeo.png" alt="A giant cartoon eyeball with a wide smile holding a wooden filing cabinet stuffed with papers while a tiny nervous developer clutches one of his own files next to it." width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At this point I am pretty sure &lt;strong&gt;Google knows me better than my own wife&lt;/strong&gt;. My wife does not remember every restaurant I have been to in the last six years. Google does. And now I was supposed to hand it a private 8 GB wedding video of a 2005 family event?&lt;/p&gt;

&lt;p&gt;No thanks. This is a small thing, but it is my small thing.&lt;/p&gt;

&lt;p&gt;There is also a purely technical annoyance on top of the privacy one. If I had used some online media conversion service, I would have had to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upload 8 GB to someone's server&lt;/li&gt;
&lt;li&gt;Wait for their queue&lt;/li&gt;
&lt;li&gt;Wait for the conversion&lt;/li&gt;
&lt;li&gt;Download the converted file&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Then&lt;/em&gt; upload it to Drive or wherever&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is a lot of babysitting for one video. And every step in that list is another server I do not control touching a private family file.&lt;/p&gt;

&lt;p&gt;Then I looked at my laptop. And my laptop had something those online services did not know about me: &lt;strong&gt;it already had ffmpeg&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wait. I can just do this myself.
&lt;/h2&gt;

&lt;p&gt;Here is the moment this post becomes a project.&lt;/p&gt;

&lt;p&gt;I have a unix system. I have &lt;code&gt;ffmpeg&lt;/code&gt;. I can install any package I need. I can convert any media format into any other format right here in my terminal, without uploading my cousin's wedding video to a single outside server. The compute is free. The storage is mine. The privacy is obvious.&lt;/p&gt;

&lt;p&gt;The only problem was: writing&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.VOB &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-crf&lt;/span&gt; 23 &lt;span class="nt"&gt;-preset&lt;/span&gt; medium &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-b&lt;/span&gt;:a 128k output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;once is fine. Writing it three times for three parts is fine. But I do this kind of thing often enough — family members handing me random files in random formats — that I knew I wanted something slightly nicer than "remember the exact ffmpeg flags forever."&lt;/p&gt;

&lt;p&gt;So over that same weekend, I started writing &lt;strong&gt;medix&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;Medix is a small Python CLI that wraps ffmpeg in a friendlier interface. The name came from "&lt;strong&gt;Media to X&lt;/strong&gt;" — you give it a media file, you pick X, it gives you X. Short, cli-friendly, does not sound generic like &lt;code&gt;mediaconverter&lt;/code&gt; or &lt;code&gt;mcc&lt;/code&gt;. MediaX → medix.&lt;/p&gt;

&lt;p&gt;The stack is honestly boring, which is the point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.9+&lt;/strong&gt; — ffmpeg as a subprocess is trivial in Python, Python already ships on macOS and most Linux distros, and I already know the language. No runtime to install for most users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://click.palletsprojects.com" rel="noopener noreferrer"&gt;click&lt;/a&gt;&lt;/strong&gt; for the CLI plumbing — small, reliable, has been around forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://rich.readthedocs.io" rel="noopener noreferrer"&gt;rich&lt;/a&gt;&lt;/strong&gt; for the terminal UI — tables, colours, actual progress bars that do not lie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://questionary.readthedocs.io" rel="noopener noreferrer"&gt;questionary&lt;/a&gt;&lt;/strong&gt; for the interactive prompts — after you give it a path, it asks you which format, which codec, which preset, which resolution, all with keyboard arrows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ffmpeg&lt;/strong&gt; for the actual conversion. Medix is a polite interface; ffmpeg does all the real work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few things I am quietly proud of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auto-install prerequisites.&lt;/strong&gt; If you do not have ffmpeg, medix detects your operating system, finds the right package manager (Homebrew, APT, DNF, Pacman, winget, Chocolatey, Scoop, and a few more) and offers to install it for you. You do not need to know what &lt;code&gt;apt-get install ffmpeg&lt;/code&gt; is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch mode.&lt;/strong&gt; Point it at a folder, optionally add &lt;code&gt;-r&lt;/code&gt; for recursive, and it converts every media file in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File discovery.&lt;/strong&gt; Before doing anything, it scans your path and shows you a clean table of what it found — resolution, duration, size — so you know exactly what is about to happen. You can bail with Ctrl-C if you scanned the wrong folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real progress bars.&lt;/strong&gt; Not fake ones that jump from 0 to 99 and freeze. It reads ffmpeg's progress output and updates per file and overall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--dry-run&lt;/code&gt; flag.&lt;/strong&gt; Because sometimes you just want to see what it &lt;em&gt;would&lt;/em&gt; do without actually running ffmpeg on 8 GB of your cousin's wedding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You install it with &lt;code&gt;pip install medix&lt;/code&gt;. It is on PyPI at &lt;a href="https://pypi.org/project/medix/" rel="noopener noreferrer"&gt;pypi.org/project/medix&lt;/a&gt;, indexed on libraries.io at &lt;a href="https://libraries.io/pypi/medix" rel="noopener noreferrer"&gt;libraries.io/pypi/medix&lt;/a&gt;, and the source lives at &lt;a href="https://github.com/vineethkrishnan/medix" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/medix&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet victory
&lt;/h2&gt;

&lt;p&gt;The 2005 wedding video converted in about 15 minutes. Three parts, all merged into one clean MP4. The file size dropped enough to share comfortably. I uploaded the result, sent it in the family group, and then I just watched.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fktwmhi3g7bfajo0hbtip.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fktwmhi3g7bfajo0hbtip.png" alt="A warm, cosy scene of an Indian family huddled together around a glowing phone, smiling and laughing as they watch a nostalgic video." width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Within minutes the group was lighting up. Elderly aunts were sending voice notes saying "oh look at so-and-so, they were so young then." Uncles were zooming in on their own faces from twenty years ago. My cousin — the actual groom — sent three laughing-crying emojis in a row, which is the closest he gets to a standing ovation.&lt;/p&gt;

&lt;p&gt;Nobody in that group knew what medix was. Nobody knew I had written a Python CLI over the weekend just to make this share possible. Nobody asked what format the file was in, or which package manager installed ffmpeg, or whether I ran it in batch mode. As far as they were concerned, I had sent a video to the group. That was the full extent of the technical appreciation.&lt;/p&gt;

&lt;p&gt;And honestly? That was the best part.&lt;/p&gt;

&lt;p&gt;When a tool you built disappears completely into the thing it was supposed to do, it means the tool worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;medix
medix ./some-old-video.vob
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or point it at a whole folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;medix ./wedding-dvd-rip &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole setup. The repo is at &lt;a href="https://github.com/vineethkrishnan/medix" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/medix&lt;/a&gt; if you want to read the code, open issues, or suggest features. I do accept contributions on this one — unlike a certain Laravel 4 starter kit of mine from another era, which is probably the next blog post.&lt;/p&gt;

&lt;p&gt;And if anyone in your family ever hands you an 8 GB .VOB file and asks you to "just send it in the group" — now at least you have a weekend project.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ffmpeg</category>
      <category>cli</category>
      <category>media</category>
    </item>
    <item>
      <title>jquery.verticalScroll.js: a love letter to jQuery, written ten years later</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:32:01 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/jqueryverticalscrolljs-a-love-letter-to-jquery-written-ten-years-later-4i7h</link>
      <guid>https://forem.com/vineethnkrishnan/jqueryverticalscrolljs-a-love-letter-to-jquery-written-ten-years-later-4i7h</guid>
      <description>&lt;h1&gt;
  
  
  jquery.verticalScroll.js: a love letter to jQuery, written ten years later
&lt;/h1&gt;

&lt;p&gt;There's a moment every developer has had. You're browsing a website — usually Apple's — and something scrolls so smoothly that your brain short-circuits from "wow, that's beautiful" to "I bet I could build that" in under three seconds.&lt;/p&gt;

&lt;p&gt;For me, it was Apple's iPhone launch page. Full-screen sections. Buttery vertical scroll. Pagination dots on the side like a quiet tour guide. I stared at it the way a dog stares at a squirrel.&lt;/p&gt;

&lt;p&gt;And then I did what any reasonable developer would do in 2016: I opened a file, typed &lt;code&gt;$.fn.verticalScroll = function()&lt;/code&gt;, and started building a jQuery plugin.&lt;/p&gt;

&lt;p&gt;Ten years later, I'm still maintaining it. It has 13 pagination themes, 16 animations, TypeScript support, visual regression tests, and a documentation site. It also has zero GitHub stars. Not one. Not even from me.&lt;/p&gt;

&lt;p&gt;This is the story of a plugin that nobody asked for, nobody uses, and I absolutely refuse to let die.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project I can't remember building it for
&lt;/h2&gt;

&lt;p&gt;Here's the thing about personal projects from a decade ago: I genuinely cannot tell you what I originally built this for. I don't even remember what I had for breakfast. What I &lt;em&gt;do&lt;/em&gt; remember is the feeling — I wanted full-page vertical scrolling in my next project, fullpage.js existed but I wanted my own, and jQuery was the hammer that fit every nail in 2016.&lt;/p&gt;

&lt;p&gt;So I built it. A lightweight plugin — pass it a container with &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt; elements, call &lt;code&gt;.verticalScroll()&lt;/code&gt;, and suddenly your page moves like an Apple keynote. Mouse wheel, keyboard arrows, touch swipe. Pagination dots that actually knew which section you were on.&lt;/p&gt;

&lt;p&gt;It worked. I used it. I loved it. And then I had the thought that turns a weekend hack into a ten-year commitment:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"This should be open source."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero stars, zero promotion, zero shame
&lt;/h2&gt;

&lt;p&gt;Let's talk about the elephant in the repo: &lt;strong&gt;zero GitHub stars.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ten years. Not a single star. And I've never once been tempted to log into an alt account and give myself one. My philosophy is simple — if someone actually likes it, they'll speak out. Spread the word, don't manufacture it.&lt;/p&gt;

&lt;p&gt;Is it a little absurd to maintain a jQuery plugin for a decade with zero external validation? Absolutely. But there's something honest about it. This project doesn't exist to pad a resume or farm engagement. It exists because I built something, I liked it, and I wasn't ready to let it rot.&lt;/p&gt;

&lt;p&gt;Some developers have side projects with thousands of stars that they abandoned two months after launch. I have one with zero stars that I gave a TypeScript rewrite last week. I know which one I respect more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dressing a ten-year-old in a tuxedo
&lt;/h2&gt;

&lt;p&gt;The original code was... let's say "young." If the first version was a newborn — tiny, adorable, running around naked and somehow still getting the job done — then what I've done over the years is slowly teach it table manners and put it in a suit.&lt;/p&gt;

&lt;p&gt;The latest version? It's wearing a tuxedo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;13 pagination themes&lt;/strong&gt; — Neon, Git Graph, Chain, Diamond, and nine others. Each one with a custom animation that matches. Because if you're going to have pagination dots, they should at least have &lt;em&gt;personality&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCSS 7-1 architecture&lt;/strong&gt; — because managing 13 themes in a single CSS file is a war crime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playwright visual regression tests&lt;/strong&gt; — every theme, every animation, pixel-checked. Because the one thing worse than a jQuery plugin nobody uses is a &lt;em&gt;broken&lt;/em&gt; jQuery plugin nobody uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docsify documentation site&lt;/strong&gt; — full API docs, examples, theme previews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release-please automation&lt;/strong&gt; — semantic versioning, changelogs, the works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v3 alpha: TypeScript + ESM&lt;/strong&gt; — yes, a jQuery plugin with TypeScript types and ES module support. In 2026. I know how that sounds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Is it overengineered for what it is? Probably. But here's the thing — I didn't add all of this at once. Each upgrade came from a genuine desire to do the thing &lt;em&gt;properly&lt;/em&gt;. If I'm going to maintain something for a decade, it might as well be something I'm proud to open the source of.&lt;/p&gt;

&lt;h2&gt;
  
  
  Will this ever be anything other than a jQuery plugin?
&lt;/h2&gt;

&lt;p&gt;Honestly? The odds that &lt;code&gt;jquery.verticalScroll.js&lt;/code&gt; becomes &lt;code&gt;react-vertical-scroll&lt;/code&gt; or &lt;code&gt;@vue/vertical-scroll&lt;/code&gt; are roughly the same as the odds that it gets its first GitHub star: nonzero, but I'm not holding my breath.&lt;/p&gt;

&lt;p&gt;The v3 TypeScript rewrite was a modernization experiment. A "what if" exercise. Could the core logic live outside jQuery? Could it become framework-agnostic? The answer is yes, technically. But "technically possible" and "something I'll actually do" are two very different things in the side-project economy.&lt;/p&gt;

&lt;p&gt;More likely, this plugin will get another quiet upgrade in 2036. jQuery 5 will exist by then (probably). I'll add Web Component support or something. The star count will still be zero. And I'll still be fine with that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;Every developer has a project like this. The one that doesn't make your portfolio shine. The one that nobody clones. The one that exists purely because &lt;em&gt;you&lt;/em&gt; wanted it to exist, and you kept choosing to make it a little better instead of letting it decay.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;jquery.verticalScroll.js&lt;/code&gt; is mine. Born because Apple made a pretty website and I had jQuery loaded. Raised through a decade of quiet commits. Dressed up in TypeScript because even old code deserves new clothes.&lt;/p&gt;

&lt;p&gt;If you somehow find it, try it, and it saves you twenty minutes — that would genuinely make my day. And if you star it? Well. That would be a first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/vineethkrishnan/jquery.verticalScroll.js" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/jquery.verticalScroll.js&lt;/a&gt;&lt;/p&gt;

</description>
      <category>jquery</category>
      <category>plugin</category>
      <category>javascript</category>
      <category>scss</category>
    </item>
    <item>
      <title>Hello World — Welcome to My Blog</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:31:20 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/hello-world-welcome-to-my-blog-2fc2</link>
      <guid>https://forem.com/vineethnkrishnan/hello-world-welcome-to-my-blog-2fc2</guid>
      <description>&lt;h1&gt;
  
  
  Hello World
&lt;/h1&gt;

&lt;p&gt;Welcome to my corner of the internet. I'm Vineeth, a Full Stack Developer with a love for building tools that solve real problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Blog?
&lt;/h2&gt;

&lt;p&gt;After years of writing code professionally, I've accumulated a lot of knowledge — patterns that work, mistakes that teach, and tools that save hours. This blog is my way of sharing all of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Expect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Technical deep dives&lt;/strong&gt; into TypeScript, Go, Rust, and Python&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI tool development&lt;/strong&gt; tips and patterns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DevOps and infrastructure&lt;/strong&gt; insights&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lessons learned&lt;/strong&gt; from production systems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open source&lt;/strong&gt; project walkthroughs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Let's Connect
&lt;/h2&gt;

&lt;p&gt;If you find something useful here, or want to discuss any topic, feel free to reach out through my &lt;a href="https://github.com/vineethkrishnan" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or drop me an email.&lt;/p&gt;

&lt;p&gt;Thanks for stopping by. More posts coming soon.&lt;/p&gt;

</description>
      <category>intro</category>
      <category>blog</category>
      <category>personal</category>
    </item>
    <item>
      <title>diskdoc and dockit: same problem, two languages, different answers</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:31:09 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/diskdoc-and-dockit-same-problem-two-languages-different-answers-54d1</link>
      <guid>https://forem.com/vineethnkrishnan/diskdoc-and-dockit-same-problem-two-languages-different-answers-54d1</guid>
      <description>&lt;h1&gt;
  
  
  diskdoc and dockit: same problem, two languages, different answers
&lt;/h1&gt;

&lt;p&gt;I built two tools that clean up disk space. Both are CLIs. Both deal with Docker. Both ship as a single binary. And if you put them side by side, they look like the same project in two languages.&lt;/p&gt;

&lt;p&gt;They're not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;diskdoc&lt;/strong&gt; is a Rust TUI that walks your entire filesystem in parallel, classifies what it finds — logs, caches, Docker artifacts, build output — and lets you browse and delete interactively. It answers the question &lt;em&gt;"where did my disk space go?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;dockit&lt;/strong&gt; is a Go CLI that talks directly to the Docker daemon, scores every resource by deletion risk, and gives you a cleanup plan. It answers the question &lt;em&gt;"which Docker resources can I safely delete?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One is wide and visual. The other is narrow and opinionated. Building both taught me things about Rust, Go, and tool design that building either alone wouldn't have.&lt;/p&gt;

&lt;p&gt;This is the third post in a series on my open-source projects. The &lt;a href="https://vineethnk.in/blog/building-backupctl" rel="noopener noreferrer"&gt;first covered backupctl&lt;/a&gt;, the &lt;a href="https://vineethnk.in/blog/building-agent-sessions" rel="noopener noreferrer"&gt;second covered agent-sessions&lt;/a&gt;. This one is a two-for-one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two tools
&lt;/h2&gt;

&lt;p&gt;The honest version: I didn't plan to build two.&lt;/p&gt;

&lt;p&gt;diskdoc came first. I wanted to learn Rust, and "build a disk usage analyzer" is the kind of project that forces you to deal with the filesystem, concurrency, error handling, and a real UI — all the things that make Rust interesting and hard. I chose it as a learning vehicle, and the tool itself was the byproduct.&lt;/p&gt;

&lt;p&gt;It worked well for general disk analysis. But when I used it to investigate Docker disk usage specifically, the limitations were obvious. diskdoc sees Docker the way &lt;code&gt;du&lt;/code&gt; sees Docker: a pile of directories under &lt;code&gt;/var/lib/docker&lt;/code&gt;. It can tell you that the &lt;code&gt;overlay2&lt;/code&gt; directory is 40GB, but it can't tell you &lt;em&gt;which images&lt;/em&gt; are eating that space, whether those images back running containers, or which ones are dangling. For that, you need to talk to the Docker daemon — not the filesystem.&lt;/p&gt;

&lt;p&gt;dockit started there. Not "let me rebuild diskdoc in Go" but "let me build the Docker-specific tool that diskdoc can't be." Go was the natural fit: Docker's own SDK is in Go, the tooling ecosystem is mature, and a single static binary is the default output, not a build target you have to configure.&lt;/p&gt;

&lt;p&gt;Two tools, two languages, two scopes. But the interesting part isn't the difference in scope — it's how the language shaped the design in ways I didn't expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rust shaped diskdoc into a TUI
&lt;/h2&gt;

&lt;p&gt;Rust's type system and ownership model made me think about the program as a &lt;em&gt;state machine&lt;/em&gt; before I wrote any UI code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Copy,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Eq)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;AppMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Scanning&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Browsing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DeleteConfirmation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Dashboard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DashboardCleanupConfirmation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;About&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;Six states. Every keypress is a transition between them. The &lt;code&gt;App&lt;/code&gt; struct holds the current mode, the file list, the selection index, and the injected dependencies — and nothing else. There's no "accidentally in two states at once" because &lt;code&gt;AppMode&lt;/code&gt; is an enum, not a pair of booleans.&lt;/p&gt;

&lt;p&gt;The TUI followed naturally. If you already have an explicit state machine, rendering becomes a pure function: take the current state, produce a frame. Ratatui (the Rust TUI library) is designed exactly for this — you call &lt;code&gt;terminal.draw(|f| draw(f, &amp;amp;app))&lt;/code&gt; in a loop, and the draw function pattern-matches on the mode to decide what to show.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;terminal&lt;/span&gt;&lt;span class="nf"&gt;.draw&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nn"&gt;tui&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nn"&gt;crossterm&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;event&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_millis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nn"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;crossterm&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;event&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="py"&gt;.mode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nn"&gt;AppMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DeleteConfirmation&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="py"&gt;.code&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nn"&gt;KeyCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'y'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nn"&gt;KeyCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Enter&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.confirm_delete&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="nn"&gt;KeyCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nn"&gt;KeyCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Esc&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.cancel_delete&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="c1"&gt;// ... other modes&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="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.on_tick&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;100ms polling. Drain up to 100 scan events per frame. Render. Repeat. The TUI stays responsive while the scanner runs on a separate thread, sending results through an &lt;code&gt;mpsc&lt;/code&gt; channel.&lt;/p&gt;

&lt;p&gt;I don't think I would have built a TUI in Go. Not because Go can't do it — it can — but because Rust's enums and match exhaustiveness made the state machine feel safe to extend. Every time I added a new mode, the compiler told me every place I needed to handle it. In Go, I'd have been maintaining that discipline manually with a &lt;code&gt;switch&lt;/code&gt; statement and hoping I didn't miss a case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go shaped dockit into an opinion
&lt;/h2&gt;

&lt;p&gt;Go didn't push me toward a TUI. It pushed me toward &lt;em&gt;decisions&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The Docker SDK is a Go library. The standard CLI toolkit (Cobra) gives you subcommands with flags. The ecosystem defaults are: structured output, composable commands, &lt;code&gt;--json&lt;/code&gt; for automation. So dockit became a tool with opinions instead of a tool with a canvas.&lt;/p&gt;

&lt;p&gt;The biggest opinion is the risk scoring system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ScoreSafe&lt;/span&gt;      &lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"SAFE"&lt;/span&gt;
    &lt;span class="n"&gt;ScoreReview&lt;/span&gt;    &lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"REVIEW"&lt;/span&gt;
    &lt;span class="n"&gt;ScoreProtected&lt;/span&gt; &lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"PROTECTED"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Docker resource — container, image, volume — gets classified. Running containers are &lt;code&gt;PROTECTED&lt;/code&gt;. Dangling images older than 7 days are &lt;code&gt;SAFE&lt;/code&gt;. Stopped containers created last week are &lt;code&gt;REVIEW&lt;/code&gt;. The scorer is 60 lines of Go, and it encodes a real position: &lt;em&gt;recently-created resources deserve a second look, even if they're unused.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Scorer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ScoreContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Container&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"running"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"restarting"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"paused"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScoreProtected&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Container is currently active"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReviewDays&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hour&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScoreReview&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Container stopped, but created recently"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScoreSafe&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Container is stopped and old"&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 what &lt;code&gt;docker system prune&lt;/code&gt; doesn't do. &lt;code&gt;prune&lt;/code&gt; is a binary: delete or don't. dockit's scoring system introduces a middle ground — "you should probably look at this before I delete it" — which is the actual mental model most developers have when cleaning up Docker resources.&lt;/p&gt;

&lt;p&gt;The cleanup command defaults to dry-run. You have to pass &lt;code&gt;--apply&lt;/code&gt; to actually delete anything, and even then it asks for confirmation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;applyCleanup&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PrintDryRun&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;diskdoc has confirmation dialogs too, but they're modal — you select an item, press &lt;code&gt;d&lt;/code&gt;, see the path, confirm. dockit shows you the &lt;em&gt;entire plan&lt;/em&gt; before you touch anything. Different UX, different safety model. The modal works for interactive browsing. The plan works for auditing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parallel scanning story (Rust)
&lt;/h2&gt;

&lt;p&gt;The piece of diskdoc that made me appreciate Rust the most is the filesystem scanner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;start_scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sender&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ScanEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;root_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="nf"&gt;.to_path_buf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nn"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;walk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;WalkDir&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;root_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.skip_hidden&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.parallelism&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Parallelism&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;RayonNewPool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;walk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir_entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dir_entry&lt;/span&gt;&lt;span class="nf"&gt;.path&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dir_entry&lt;/span&gt;&lt;span class="nf"&gt;.metadata&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                    &lt;span class="c1"&gt;// ... classify and send&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ScanEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;NewEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.is_err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Receiver dropped&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ScanEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.to_string&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ScanEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Complete&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;jwalk&lt;/code&gt; is the crate doing the heavy lifting — it wraps Rayon to walk directories across 4 threads, and the results feed into an unbounded &lt;code&gt;mpsc&lt;/code&gt; channel. The TUI's &lt;code&gt;on_tick&lt;/code&gt; drains up to 100 events per frame, so the UI stays responsive even when the scanner is churning through millions of files.&lt;/p&gt;

&lt;p&gt;What I like about this code is what it &lt;em&gt;doesn't&lt;/em&gt; need. No mutex around the file list. No lock on the scan state. The channel is the synchronization primitive, and Rust's ownership model guarantees that the sender and receiver can't race on shared data because there &lt;em&gt;is&lt;/em&gt; no shared data. The scanner owns its thread. The UI owns the &lt;code&gt;App&lt;/code&gt; struct. They communicate through messages.&lt;/p&gt;

&lt;p&gt;In Go I would have reached for a goroutine and a channel, which is structurally similar — but the safety guarantee would be convention, not compilation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Docker SDK story (Go)
&lt;/h2&gt;

&lt;p&gt;dockit's advantage is that it doesn't guess about Docker. It asks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GetContainers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Container&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rawContainers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContainerList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContainerListOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;All&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="c"&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;Size: true&lt;/code&gt; is the detail that matters. Without it, the Docker daemon returns containers without calculating their writable layer size — which makes the output useless for disk analysis. With it, the daemon does the work (it takes a bit longer), and you get real numbers.&lt;/p&gt;

&lt;p&gt;But the SDK has quirks. &lt;code&gt;Image.Containers&lt;/code&gt; — the field that tells you how many containers use an image — is unreliable. &lt;code&gt;ImageList&lt;/code&gt; returns &lt;code&gt;-1&lt;/code&gt; for this field unless you've recently called &lt;code&gt;/system/df&lt;/code&gt;. So dockit does its own cross-reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;containers&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Container&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;images&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;volumes&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Volume&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CorrelatedData&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;imgUsage&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;containers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;imgUsage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ImageID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;images&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;localCount&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;imgUsage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;localCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Containers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Containers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;localCount&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;CorrelatedData&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Containers&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;containers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Images&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Volumes&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;volumes&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;Build a map of image ID to container count. If the local count is higher than what Docker reported, use the local count. This is the kind of detail you only discover by testing against real Docker daemons — the SDK documentation doesn't warn you.&lt;/p&gt;

&lt;p&gt;diskdoc can't do any of this. It sees &lt;code&gt;/var/lib/docker/overlay2&lt;/code&gt; as a directory and reports its size. Useful, but it can't tell you that half of those layers belong to a dangling image you pulled six months ago and forgot about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The heuristics chain (Rust) vs the scorer (Go)
&lt;/h2&gt;

&lt;p&gt;Both tools need to classify what they find. They solve this differently, and the difference reveals something about what each tool values.&lt;/p&gt;

&lt;p&gt;diskdoc uses a chain-of-responsibility pattern with pluggable heuristics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;HeuristicsEngine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;heuristics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="n"&gt;Heuristic&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;HeuristicsEngine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;heuristics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="nn"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogHeuristic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nn"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NpmHeuristic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nn"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ComposerHeuristic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nn"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AptHeuristic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nn"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CacheHeuristic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nn"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DockerHeuristic&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="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;FileType&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.heuristics&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="nf"&gt;.detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;t&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="nn"&gt;FileType&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Normal&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First match wins. Order matters — &lt;code&gt;NpmHeuristic&lt;/code&gt; runs before &lt;code&gt;CacheHeuristic&lt;/code&gt; so that &lt;code&gt;.npm/_cacache&lt;/code&gt; gets flagged as npm cache, not generic cache. Each heuristic is a trait implementation, so adding a new one means writing a struct with a &lt;code&gt;detect&lt;/code&gt; method and inserting it at the right position in the chain.&lt;/p&gt;

&lt;p&gt;This works for filesystem analysis because the input is a &lt;em&gt;path&lt;/em&gt;. Paths are simple. You can match on extensions, directory names, well-known locations. The heuristic doesn't need context beyond the path itself.&lt;/p&gt;

&lt;p&gt;dockit's scorer is different. It doesn't look at paths — it looks at &lt;em&gt;state&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Scorer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ScoreImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Containers&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScoreProtected&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Image is currently backing a container"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dangling&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScoreReview&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Image specifies a repository/tag but is unused"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScoreReview&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Image is dangling but was created recently"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScoreSafe&lt;/span&gt;
    &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Image is dangling and old"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scorer knows about relationships (does this image back a container?), temporal context (how old is it?), and naming (is it dangling?). It's not matching strings — it's reasoning about the resource graph. This is only possible because dockit has the Docker SDK, which gives it the full picture.&lt;/p&gt;

&lt;p&gt;The heuristics chain is extensible. The scorer is opinionated. Both are right for their context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runaway log problem
&lt;/h2&gt;

&lt;p&gt;One of dockit's most practical features is &lt;code&gt;dockit logs&lt;/code&gt; — it finds containers whose log files are silently eating disk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GetLogMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LogMetrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cnt&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;containers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContainerInspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cnt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LogMetrics&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;ContainerID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;cnt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ContainerName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;LogPath&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LogPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LogPath&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LogPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LogSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Size&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="n"&gt;metrics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker stores container logs at &lt;code&gt;/var/lib/docker/containers/{id}/{id}-json.log&lt;/code&gt;. If a container writes without rotation — no &lt;code&gt;--log-opt max-size&lt;/code&gt; — that file grows until the disk fills. I've seen 30GB log files from a single container that printed debug output nobody was reading.&lt;/p&gt;

&lt;p&gt;The trick is the &lt;code&gt;ContainerInspect&lt;/code&gt; call. The container list doesn't include the log path — you have to inspect each container individually to get &lt;code&gt;info.LogPath&lt;/code&gt;, then &lt;code&gt;os.Stat&lt;/code&gt; the file. It's an N+1 query pattern, but for Docker containers (usually dozens, not thousands) it's fine.&lt;/p&gt;

&lt;p&gt;diskdoc would see the same files, but it would flag them as generic log files in &lt;code&gt;/var/lib/docker&lt;/code&gt;. It wouldn't know which container produced them, whether that container is still running, or whether the log driver is misconfigured. Context matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the comparison taught me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Language shapes design more than you expect.&lt;/strong&gt; I didn't choose to build a TUI in Rust and a CLI in Go because I sat down and evaluated the tradeoffs. Rust's type system made a state machine feel natural, and ratatui was right there. Go's Docker SDK and Cobra ecosystem made structured CLI output feel natural, and &lt;code&gt;--json&lt;/code&gt; was right there. The languages didn't force the designs, but they made certain designs &lt;em&gt;frictionless&lt;/em&gt; — and friction is what kills side projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope is a design decision, not a limitation.&lt;/strong&gt; diskdoc's broad scope (all files, all types) means it's useful everywhere but shallow on any specific domain. dockit's narrow scope (Docker only) means it's useless outside Docker but deep where it matters — risk scoring, log detection, SDK-level introspection. Neither is better. They serve different moments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safety has more than one shape.&lt;/strong&gt; diskdoc uses confirmation modals: select, press delete, confirm. dockit uses a three-layer system: classify, plan, confirm. The modal works when you're &lt;em&gt;browsing&lt;/em&gt; and spot something to delete. The plan works when you want to &lt;em&gt;audit&lt;/em&gt; before you act. I use both, for different situations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two small tools beat one clever tool.&lt;/strong&gt; I could have tried to merge diskdoc and dockit — a Rust TUI that also talks to the Docker daemon. It would have been harder to build, harder to test, harder to release. Instead I have two binaries that do one thing each. The Unix way isn't always the right way, but for developer tools that solve a specific itch, it usually is.&lt;/p&gt;

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

&lt;p&gt;Both tools are open source:&lt;/p&gt;

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

&lt;p&gt;This is the third post in the series. If you're curious about the projects or want to tell me why I should have written both in Zig, find me on &lt;a href="https://github.com/vineethkrishnan" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>go</category>
      <category>cli</category>
      <category>docker</category>
    </item>
    <item>
      <title>dfree: stop digging with your hands, you've got an axe now</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:30:28 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/dfree-stop-digging-with-your-hands-youve-got-an-axe-now-2pi0</link>
      <guid>https://forem.com/vineethnkrishnan/dfree-stop-digging-with-your-hands-youve-got-an-axe-now-2pi0</guid>
      <description>&lt;h1&gt;
  
  
  dfree: stop digging with your hands, you've got an axe now
&lt;/h1&gt;

&lt;p&gt;There's a special kind of panic that hits when someone pings you at work and says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Vineeth, we can't upload anymore."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not "something is slow." Not "there's a bug." Just — the server is full. Done. No more room at the inn.&lt;/p&gt;

&lt;p&gt;The setup was innocent enough. A small internal hosting for a knowledge base and academy videos. Upload feature included — so the customer support team could add training materials themselves. Worked great. Worked &lt;em&gt;too&lt;/em&gt; great. Turns out, when you give people an upload button and no quota, they will fill your disk with the enthusiasm of a golden retriever discovering a mud puddle.&lt;/p&gt;

&lt;p&gt;So there I was, SSH'd into the server, running &lt;code&gt;du -sh /*&lt;/code&gt; like a caveman, trying to figure out which directory ate 90% of the disk. Docker images from six months ago? Still there. npm cache from a build pipeline nobody remembers? Thriving. Log files older than some of my git repositories? Absolutely.&lt;/p&gt;

&lt;p&gt;I needed a tool. Not a monitoring dashboard. Not an alert system. Just something that could look at a server and say: &lt;em&gt;"Here's what's eating your disk. Want me to clean it? Yes or no."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;dfree&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One command. That's it.
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dfree
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire interface. Run it, and it scans your system in four passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;=== System Analysis ===

[INFO] Scanning disk usage...
500G 387G 113G 78%

[INFO] Scanning Docker usage...
Images: 12.4GB (8.2GB reclaimable)
Containers: 1.1GB (900MB reclaimable)
Build Cache: 5.6GB

[INFO] Scanning Developer Caches...
  - /home/vineeth/.npm/_cacache: 1240MB
  - /home/vineeth/.cache/pip: 680MB
  - /home/vineeth/.cargo/registry/cache: 340MB
  - /home/vineeth/.gradle/caches: 2100MB
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That Gradle cache sitting at 2.1GB and contributing absolutely nothing to society? Yeah. dfree found it.&lt;/p&gt;

&lt;h2&gt;
  
  
  It asks before it touches anything
&lt;/h2&gt;

&lt;p&gt;This is the part I care about most. dfree will &lt;strong&gt;never&lt;/strong&gt; delete something without asking first. Every single action gets a confirmation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;=== Cleanup Process ===

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

Clean system cache at /var/cache/apt/archives? [y/N] y
Clean system cache at /var/log/journal? [y/N] n
Clean developer cache at /home/vineeth/.npm/_cacache? [y/N] y
Clean developer cache at /home/vineeth/.gradle/caches? [y/N] y

Empty Trash? [y/N] y

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

&lt;/div&gt;



&lt;p&gt;No flags to memorize. No config files to write. Just a conversation between you and your disk, one directory at a time. Say yes to what you want gone, no to what you don't.&lt;/p&gt;

&lt;p&gt;If you're the kind of person who doesn't trust a tool that says "I'll be careful" — good, me neither. That's what &lt;code&gt;--simulate&lt;/code&gt; is for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dfree &lt;span class="nt"&gt;--simulate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[WARN] RUNNING IN SIMULATION MODE (No files will be deleted)
[INFO] SIMULATE: docker system prune -f
[INFO] SIMULATE: rm -rf /home/vineeth/.npm/_cacache
[INFO] SIMULATE: rm -rf /home/vineeth/.gradle/caches
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All the analysis, none of the consequences. Like window shopping, but for disk space.&lt;/p&gt;

&lt;h2&gt;
  
  
  It knows what NOT to delete
&lt;/h2&gt;

&lt;p&gt;Behind every &lt;code&gt;rm -rf&lt;/code&gt; there's a safety check. dfree maintains a blocklist of paths that should never be touched:&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;# You literally cannot delete these, even if you try&lt;/span&gt;
/ /bin /usr /etc /boot /System /Applications
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also rejects relative paths (no &lt;code&gt;../../oops&lt;/code&gt;) and checks your exclusion list from &lt;code&gt;.dfreerc&lt;/code&gt;. Because the only thing worse than a full disk is an empty one that used to have your operating system on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom rules for your mess
&lt;/h2&gt;

&lt;p&gt;Every server has its own flavor of clutter. dfree lets you teach it yours with a &lt;code&gt;~/.dfreerc&lt;/code&gt; file:&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;# ~/.dfreerc&lt;/span&gt;

&lt;span class="c"&gt;# "Please also look at these"&lt;/span&gt;
&lt;span class="nv"&gt;DFREE_CUSTOM_PATHS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"/data/academy/temp-uploads"&lt;/span&gt; &lt;span class="s2"&gt;"/var/tmp/build-artifacts"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# "Never touch these, I mean it"&lt;/span&gt;
&lt;span class="nv"&gt;DFREE_EXCLUDED_PATHS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"/data/academy/production-videos"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The custom paths show up in the analysis and get offered for cleanup. The excluded paths are invisible to the cleaner — dfree won't even ask about them. Because that &lt;code&gt;/production-videos&lt;/code&gt; directory? That one is sacred. That one stays.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it hunts
&lt;/h2&gt;

&lt;p&gt;Here's the full hit list, depending on your OS:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker&lt;/strong&gt; — dangling images, stopped containers, orphan networks, build cache. The stuff Docker hoards like it's preparing for winter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;System caches&lt;/strong&gt; — &lt;code&gt;/var/cache/apt/archives&lt;/code&gt; on Linux, &lt;code&gt;~/Library/Caches&lt;/code&gt; on macOS. The files your OS downloaded once and will never look at again but refuses to throw away, like a digital hoarder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Developer caches&lt;/strong&gt; — npm, yarn, pip, cargo, go-build, gradle. Six package managers, six caches, one combined guilt trip. That &lt;code&gt;node_modules&lt;/code&gt; might be gone, but the cache remembers everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs&lt;/strong&gt; — journal logs on Linux, &lt;code&gt;~/Library/Logs&lt;/code&gt; on macOS. Your system's diary, except nobody reads it and it never stops writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trash&lt;/strong&gt; — yes, the actual trash. Because "I'll empty it later" is a lie we all tell ourselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Works everywhere you SSH into
&lt;/h2&gt;

&lt;p&gt;dfree auto-detects whether it's on Linux or macOS and adapts. Different paths for caches, different commands for disk stats, same interactive flow. You don't configure this — it just figures it out.&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;# Linux&lt;/span&gt;
get_system_cache_paths → /var/cache/apt/archives, /var/log/journal, ~/.cache

&lt;span class="c"&gt;# macOS&lt;/span&gt;
get_system_cache_paths → ~/Library/Caches, ~/Library/Logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One tool, two operating systems, zero flags to remember.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install in 10 seconds
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/vineethkrishnan/dfree.git
&lt;span class="nb"&gt;cd &lt;/span&gt;dfree
./install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. It's a shell script. It doesn't need a runtime, a package manager, or a prayer. If your server has bash, it has dfree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;dfree isn't clever. It doesn't predict what you should delete. It doesn't auto-schedule. It doesn't send you a weekly report with pie charts.&lt;/p&gt;

&lt;p&gt;It does one thing: it walks through your disk, shows you what's taking space, and asks — one item at a time — if you want it gone. That's it. That's the whole tool.&lt;/p&gt;

&lt;p&gt;Because when your server is full and someone is waiting to upload the next training video, you don't need a dashboard. You need an axe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/vineethkrishnan/dfree" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/dfree&lt;/a&gt;&lt;/p&gt;

</description>
      <category>shell</category>
      <category>cli</category>
      <category>devops</category>
      <category>diskcleanup</category>
    </item>
    <item>
      <title>Building mcp-pool: one week, eleven MCP servers, one shared OAuth library</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:30:17 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/building-mcp-pool-one-week-eleven-mcp-servers-one-shared-oauth-library-3m5h</link>
      <guid>https://forem.com/vineethnkrishnan/building-mcp-pool-one-week-eleven-mcp-servers-one-shared-oauth-library-3m5h</guid>
      <description>&lt;h1&gt;
  
  
  Building mcp-pool: one week, eleven MCP servers, one shared OAuth library
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb0qainnnxpxok54swvup.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb0qainnnxpxok54swvup.png" alt="A developer at a laptop with a glowing circular pool on the desk in front of him filled with floating SaaS icons — cards, bug shapes, notebooks, calendars, charts — all connected by thin threads into his laptop." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is another post in the series where I walk through my open-source projects. Earlier ones covered &lt;a href="https://vineethnk.in/blog/building-backupctl" rel="noopener noreferrer"&gt;backupctl&lt;/a&gt;, &lt;a href="https://vineethnk.in/blog/building-agent-sessions" rel="noopener noreferrer"&gt;agent-sessions&lt;/a&gt;, and a few smaller tools. This one is about &lt;strong&gt;mcp-pool&lt;/strong&gt; — a monorepo of MCP servers for the SaaS tools I actually use at work.&lt;/p&gt;

&lt;p&gt;It started as a Stripe MCP server. One package, one weekend. Somewhere along the way it grew into eleven — Stripe, Sentry, Notion, Linear, Datadog, Vercel, PagerDuty, HubSpot, Intercom, Shopify, Google Workspace — plus a shared OAuth library holding them together. Most of that happened faster than I'd planned, partly because the monorepo setup I did on day one kept paying off, and partly because the Claude Code sessions I used to scaffold each server got better every time I ran one.&lt;/p&gt;

&lt;p&gt;The interesting part is not the package count. Pasting SDK code into eleven folders is not engineering. The interesting part is what happened a few days in, when I realised six of those servers were going to need OAuth and I was about to write more or less the same auth flow six times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Stripe first
&lt;/h2&gt;

&lt;p&gt;Before any of the architecture decisions, there was a small thing that kept bugging me.&lt;/p&gt;

&lt;p&gt;I was using Claude Code heavily for day job stuff, and at some point I noticed I had three browser tabs open all the time — Stripe dashboard, Sentry, and a Notion page with our on-call runbook. Every "why did this customer's payment fail" or "what did this error look like yesterday" started the same way. Switch to browser. Find the tab. Log in again because the session expired. Copy something. Come back to the editor. Paste. Ask the agent to keep going.&lt;/p&gt;

&lt;p&gt;MCP was already in the picture by then. I had wired up a couple of third-party servers and they mostly worked, but the ones I wanted most were either missing, or heavyweight, or maintained by someone whose priorities were clearly different from mine. So one evening I sat down and told myself: just build one. Start with Stripe because it's the one I open most, and also the one where the API is so well-documented that getting a clean read-only subset working is basically a weekend project.&lt;/p&gt;

&lt;p&gt;That was the whole plan. One server. Ship it to npm. Move on.&lt;/p&gt;

&lt;p&gt;I did not move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day one: the monorepo decision
&lt;/h2&gt;

&lt;p&gt;This is where I made the first choice that paid off for the rest of the week.&lt;/p&gt;

&lt;p&gt;A tiny voice said: just ship Stripe as a single repo. Call it &lt;code&gt;stripe-mcp&lt;/code&gt;, npm it, be done. I ignored the voice. If I was going to write one MCP server, there was at least a fifty percent chance I'd write another one next month. And if I wrote two, I was absolutely going to hate myself for duplicating the build config, the test setup, the release pipeline, the lint rules.&lt;/p&gt;

&lt;p&gt;So day one, before writing any Stripe code, I set up a monorepo. npm workspaces. TypeScript strict mode. Jest with a 100% line coverage target. ESLint, Prettier, husky, commitlint. release-please for independent versioning per package. A Docusaurus site scaffolded next to the packages folder.&lt;/p&gt;

&lt;p&gt;This felt like overengineering for one server. In practice it paid for itself as soon as the second package showed up — no rebuilding CI, no rewriting release config, no fighting the test runner. Every package I added after Stripe was basically drop-in.&lt;/p&gt;

&lt;p&gt;The initial commit went in as &lt;code&gt;406ad19: initial monorepo setup with stripe-mcp and docusaurus&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sentry next, and the self-hosted thing
&lt;/h2&gt;

&lt;p&gt;Stripe was in a working state within a few hours. I published it, opened Claude Desktop, pointed it at the package, and asked &lt;em&gt;"how many active subscriptions do we have"&lt;/em&gt;. It answered. That was a good moment.&lt;/p&gt;

&lt;p&gt;Next one was obvious. Most of my error-investigation workflow goes through Sentry, but we run a &lt;strong&gt;self-hosted&lt;/strong&gt; Sentry instance, not the SaaS one. And every Sentry MCP server I checked on npm had the base URL hardcoded to &lt;code&gt;sentry.io&lt;/code&gt;. Fine for most users. Useless for me and for everyone else I know who runs Sentry on their own infrastructure for compliance or cost reasons.&lt;/p&gt;

&lt;p&gt;So I built the Sentry MCP with a &lt;code&gt;SENTRY_BASE_URL&lt;/code&gt; env var from the very first commit. Default to &lt;code&gt;sentry.io&lt;/code&gt; if not set. Point it at &lt;code&gt;https://sentry.yourdomain.tld&lt;/code&gt; if you self-host. No forks, no patches, same npm package for both worlds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sentry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@vineethnkrishnan/sentry-mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"SENTRY_AUTH_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sntrys_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"SENTRY_BASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://sentry.mydomain.tld"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the one I use most, and it has quietly changed how I deal with errors during the day. Before, a Sentry issue in a Slack alert meant: click the link, switch tabs, log back in because the session expired, read the stack trace, copy a snippet, go back to the editor, paste, think. Now it is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Pull up the latest unresolved issue in project &lt;code&gt;api&lt;/code&gt;, show me the stack trace, and suggest a fix."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent calls the Sentry MCP, gets the issue, pulls the event with the stack frames, and either proposes a code change or drills into the file directly. When I'm happy with the fix, I tell it to resolve the issue in Sentry and it does. The write-ops version (more on that later) means the whole loop — investigate, fix, resolve — stays in one place, against my own self-hosted instance, without me touching the dashboard. That part is genuinely the reason I kept building the rest of the pool.&lt;/p&gt;

&lt;p&gt;Once Stripe and Sentry were both shipping, I had two packages in the monorepo — and the duplication started showing up. Not in a scary way, but enough that my lint tool flagged it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall I was about to hit
&lt;/h2&gt;

&lt;p&gt;I opened a new Claude Code session and just said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Yes I need to add oAuth to the supporting mcp packages, so what could be the architecture?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence was the entire setup for the next two days of work.&lt;/p&gt;

&lt;p&gt;Here's what I was staring at. Stripe uses a static API key. Sentry uses a static API token. Easy. But the ones I wanted next — Notion, Linear, HubSpot, Intercom, Shopify, Google Workspace — all need OAuth2. And "OAuth2" is one of those phrases that sounds like a single protocol but is actually six slightly different protocols that agree on the overall shape and disagree on every small thing that matters.&lt;/p&gt;

&lt;p&gt;Each one was going to need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A local token file at some path like &lt;code&gt;~/.mcp-tokens/&amp;lt;server&amp;gt;.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A browser-based login flow with a callback server&lt;/li&gt;
&lt;li&gt;Token exchange, refresh, and caching&lt;/li&gt;
&lt;li&gt;A CLI command to log in and log out&lt;/li&gt;
&lt;li&gt;The actual SDK integration on top of all that&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I wrote this six times, copy-paste style, three things would happen. One, I'd get bored around server three and start cutting corners. Two, the first time I found a bug in the refresh logic I'd have to fix it in six places. Three, &lt;code&gt;jscpd&lt;/code&gt; (a code-duplication detector in my CI) would start blocking PRs because the overlap would be enormous.&lt;/p&gt;

&lt;p&gt;The correct answer was obvious in hindsight. The only reason it was not obvious earlier is that I had two packages. With two, you don't see the pattern. With six pending, you can't not see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  oauth-core
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feqdzltel7tmni1pe5d4n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feqdzltel7tmni1pe5d4n.png" alt="A central glowing orb with six clean cables running out to six small labeled boxes around it, next to a faded image of a tangled knot of wires being replaced." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I extracted a package called &lt;code&gt;@vineethnkrishnan/oauth-core&lt;/code&gt;. Its job is small and boring, which is exactly what you want from an infrastructure package.&lt;/p&gt;

&lt;p&gt;It exposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;TokenProvider&lt;/code&gt; interface that hides the difference between OAuth and static-key auth, so the MCP tool code doesn't have to care which one it's using.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;LocalFileTokenStore&lt;/code&gt; that handles reading, writing, and migrating those &lt;code&gt;~/.mcp-tokens/&amp;lt;server&amp;gt;.json&lt;/code&gt; files.&lt;/li&gt;
&lt;li&gt;A set of OAuth strategies — authorization code, refresh, PKCE — that each server can pick from by passing a small config object.&lt;/li&gt;
&lt;li&gt;A CLI helper that wires up &lt;code&gt;&amp;lt;server&amp;gt; auth login&lt;/code&gt; and &lt;code&gt;&amp;lt;server&amp;gt; auth logout&lt;/code&gt; commands so every server has the same UX.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The per-server OAuth config ends up looking 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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notionAuthConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OAuthProviderConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;authorizationUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.notion.com/v1/oauth/authorize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tokenUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.notion.com/v1/oauth/token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;clientIdEnv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NOTION_CLIENT_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;clientSecretEnv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NOTION_CLIENT_SECRET&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's basically the whole auth setup for Notion. Same for Linear, HubSpot, Intercom, Shopify, Google Workspace — each one is a dozen lines of config instead of a few hundred lines of duplicated flow code. The commit that landed this was &lt;code&gt;417d8a7: feat(oauth): add shared oauth-core package and integrate across 6 mcp servers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One thing I want to be honest about: the first version of &lt;code&gt;oauth-core&lt;/code&gt; was too clever. I tried to abstract every possible OAuth variant behind a single interface and ended up with a config object that had more fields than some of the SDKs I was wrapping. I threw it away and started over with the rule that if a provider doesn't need a field, the field doesn't exist. The second version was boring, readable, and has not needed a breaking change since.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding the rest
&lt;/h2&gt;

&lt;p&gt;Once &lt;code&gt;oauth-core&lt;/code&gt; was in place, writing a new server stopped feeling like a project and started feeling like filling in a form.&lt;/p&gt;

&lt;p&gt;Each new one was a small amount of actually-new code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A thin &lt;code&gt;services/&lt;/code&gt; layer wrapping the official SDK (if one existed) or &lt;code&gt;fetch&lt;/code&gt; (if not).&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;tools/&lt;/code&gt; folder defining the MCP tool schemas — what the AI agent sees as callable functions.&lt;/li&gt;
&lt;li&gt;Tests. Real tests, not aspirational tests. Every package had to hit the coverage target before it was allowed to merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I added Linear, Notion, Vercel, Datadog, HubSpot, Intercom, Shopify, PagerDuty, and Google Workspace this way — one after another, each building on what the previous one had already figured out. The commit that landed them is &lt;code&gt;f137e0c: add 9 new mcp servers for popular saas platforms&lt;/code&gt;, but the commit itself isn't the interesting thing. The interesting thing is that the auth config was small, the SDK wrappers were small, and the tool definitions were the only piece that actually needed thought per-provider.&lt;/p&gt;

&lt;p&gt;The tests, for what it's worth, were not negotiable. I wasn't moving fast because I was skipping them. I was moving fast because every previous package had tests, which meant when I copied its structure I inherited a working test pattern instead of a blank file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping is not enough
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ystsf9ed2xmhgrl7h3m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ystsf9ed2xmhgrl7h3m.png" alt="A calendar showing one week with small glowing package boxes stacked higher each day, and a tired but pleased developer glancing at it with a coffee cup." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At some point the eleven packages were on npm, the docs site was live, and &lt;code&gt;oauth-core&lt;/code&gt; was carrying the shared weight. I thought the hard part was done.&lt;/p&gt;

&lt;p&gt;Then I went to Glama — the community registry that a lot of people use to discover MCP servers — and noticed something. My entries were there, but they showed up with "security and quality not tested" badges. Which is a polite way of saying "this might be junk". Even the great-looking packages in my monorepo were sitting behind those badges, invisible to anyone who filters by verified servers.&lt;/p&gt;

&lt;p&gt;So there was a whole second phase of work that was just about being a good citizen of the ecosystem. I added a &lt;code&gt;Dockerfile&lt;/code&gt; so the Glama sandbox could spin up each server and verify it responded to introspection. I added read-only tool annotations so clients could show users which tools were safe and which ones wrote data. I added &lt;code&gt;glama.json&lt;/code&gt; metadata and an MCP Registry manifest. I added a CI workflow that built the Docker image and ran smoke tests. None of this was code users would ever see, and all of it was required to actually be &lt;em&gt;findable&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This was the moment the project stopped being "I built some MCP servers" and started being "I run an MCP server pool that people can actually adopt without vetting from scratch". Different kind of work, same project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write operations, eventually
&lt;/h2&gt;

&lt;p&gt;The first ten days of the project were deliberately read-only. Read Stripe balances, read Sentry issues, read Linear tickets. No creating, updating, or deleting anything. I wanted the safety story to be &lt;em&gt;obvious&lt;/em&gt; — if the AI agent goes off the rails, at worst it reads something it shouldn't have.&lt;/p&gt;

&lt;p&gt;Write ops landed in commit &lt;code&gt;24d762c: add write operations to all 11 mcp servers&lt;/code&gt;, and I structured them with one hard rule: &lt;strong&gt;write tools are opt-in per server, not global&lt;/strong&gt;. You set an env var to enable them. Otherwise the server starts in read-only mode and the write tools are not even exposed to the agent. This keeps the default safe and lets users flip the switch only for the servers where they actually want the agent to take action.&lt;/p&gt;

&lt;p&gt;If you're wondering why this is a big deal: an MCP tool is just a function signature in the agent's context. If the tool exists, the agent may call it. The only way to really prevent it is to not advertise the tool in the first place. Env-gated tools make this a boring one-line config instead of a runtime check that might leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrospective
&lt;/h2&gt;

&lt;p&gt;Looking back at the repo, a few things worked and a few I'd do differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo before the second package.&lt;/strong&gt; Setting up the monorepo before writing any Stripe code was the most useful decision of the whole project. Every package after Stripe was drop-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extracting oauth-core at the right moment.&lt;/strong&gt; Too early, and I would have built the wrong abstraction from Stripe's static-key auth. Too late, and I would have been refactoring six packages at once. Roughly the right time was when the third OAuth server was about to be written — hold off until then, then stop and extract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;100% line coverage as a merge gate.&lt;/strong&gt; Sounds harsh. In practice it mostly just forced me to structure code in ways that were naturally testable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted support from day one in Sentry.&lt;/strong&gt; Small environmental detail, but it's the thing that actually made the MCP useful for my own day-to-day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I'd change:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first version of &lt;code&gt;oauth-core&lt;/code&gt;.&lt;/strong&gt; As mentioned, it was too abstract. I caught it in the same week, but I'd catch it earlier next time by writing the second caller of a new abstraction &lt;em&gt;before&lt;/em&gt; finalising the API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker and Glama from day one.&lt;/strong&gt; I treated discoverability as a phase-two concern. Should have been phase-one, alongside npm publishing. A package nobody finds doesn't help anyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The docs site.&lt;/strong&gt; Docusaurus is fine, but the default templates eat a lot of configuration time. Next time I'd pick something more minimal or start with the README-only approach and let docs grow organically.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The public roadmap is in &lt;code&gt;roadmap/&lt;/code&gt; inside the repo. Short version: v0.2.0 is landing write ops across the board (already done, mostly), SSE transport, and streaming responses. v0.3.0 is about webhooks, multi-account support — "show me Stripe balances for &lt;em&gt;both&lt;/em&gt; tenants" — and a few more servers people have asked for (GitHub, Slack, Airtable are the three loudest).&lt;/p&gt;

&lt;p&gt;If you're someone who also spends their day in three SaaS dashboards and an AI agent, &lt;code&gt;mcp-pool&lt;/code&gt; is on &lt;a href="https://www.npmjs.com/~vineethnkrishnan" rel="noopener noreferrer"&gt;npm&lt;/a&gt; and the source is on &lt;a href="https://github.com/vineethkrishnan/mcp-pool" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The docs are at &lt;a href="https://mcp-pool.vineethnk.in" rel="noopener noreferrer"&gt;mcp-pool.vineethnk.in&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That's it for this one. Thanks for reading — see you in the next post.&lt;/p&gt;

&lt;p&gt;Bis bald.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>mcp</category>
      <category>oauth</category>
      <category>monorepo</category>
    </item>
    <item>
      <title>Building docling-server: a one-command document API for our AI pipeline</title>
      <dc:creator>Vineeth N Krishnan</dc:creator>
      <pubDate>Wed, 22 Apr 2026 05:21:01 +0000</pubDate>
      <link>https://forem.com/vineethnkrishnan/building-docling-server-a-one-command-document-api-for-our-ai-pipeline-4885</link>
      <guid>https://forem.com/vineethnkrishnan/building-docling-server-a-one-command-document-api-for-our-ai-pipeline-4885</guid>
      <description>&lt;h1&gt;
  
  
  Building docling-server: a one-command document API for our AI pipeline
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnzk7dt37rt8dompuwecz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnzk7dt37rt8dompuwecz.png" alt="A tired developer at a laptop feeding a giant stack of messy paper documents into a glowing machine on his desk, and clean markdown scrolls coming out the other side, editorial illustration style." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is another one in the series where I walk through my open-source projects. Earlier ones covered &lt;a href="https://vineethnk.in/blog/building-backupctl" rel="noopener noreferrer"&gt;backupctl&lt;/a&gt;, &lt;a href="https://vineethnk.in/blog/building-mcp-pool" rel="noopener noreferrer"&gt;mcp-pool&lt;/a&gt;, and a few smaller tools. Today it is &lt;strong&gt;docling-server&lt;/strong&gt; — the thing I built when our AI project needed a proper way to turn messy documents into clean markdown, and calling docling directly from the app started feeling like a bad idea.&lt;/p&gt;

&lt;p&gt;If you have not seen &lt;a href="https://docling-project.github.io/docling/" rel="noopener noreferrer"&gt;docling&lt;/a&gt; yet, it is IBM's document processing library. PDF, DOCX, PPTX, scanned images, tables, the whole lot — out comes structured output. Very good at its job. The problem is not docling. The problem is everything around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The itch
&lt;/h2&gt;

&lt;p&gt;So the AI project needed document ingestion. And by "document" I do not just mean PDFs. Users would drop in whatever they had lying around — Word files, rich text, PowerPoint decks, scanned contracts, invoices, internal reports in weird old formats, the kind of unstructured stuff nobody formats nicely — and the pipeline had to pull clean text out of every one of them. Clean enough that the downstream steps could actually reason about it.&lt;/p&gt;

&lt;p&gt;You can call docling as a library. In a notebook it is lovely. In a production service it gets awkward fast. First, the conversions are slow. A scanned document running through OCR can easily take longer than any sane HTTP request should. Second, docling pulls in a small forest of dependencies — OCR engines, model weights, CUDA bits if you want the GPU path. You do not really want that zoo living inside your main app container.&lt;/p&gt;

&lt;p&gt;And there was one more thing. We had this Hetzner GEX box sitting there with a proper GPU on it, exactly for heavy lifting like this. It made no sense to install docling into the app. The AI app should ask a question. The GPU box should do the work.&lt;/p&gt;

&lt;p&gt;What was missing was the thin layer in between. An HTTP endpoint that says &lt;em&gt;here is a document, give me markdown back&lt;/em&gt;. That layer is what became docling-server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "one command" actually means
&lt;/h2&gt;

&lt;p&gt;The pitch is small. Clone the repo, fill in a &lt;code&gt;.env&lt;/code&gt;, run &lt;code&gt;make init&lt;/code&gt;. You get a protected HTTP endpoint over SSL — API key in the header, Let's Encrypt doing the certificate bit, nginx enforcing TLS on the edge — that takes a file or a URL and returns markdown, JSON, or plain text. That is it.&lt;/p&gt;

&lt;p&gt;Under the hood it is less small. The compose file spins up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;nginx&lt;/strong&gt; for the reverse proxy, with Let's Encrypt doing the TLS bit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; for the actual API endpoints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Celery workers&lt;/strong&gt; doing the real document conversion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis&lt;/strong&gt; as the broker and result backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flower&lt;/strong&gt; for a dashboard to peek at what the workers are up to&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The split matters. FastAPI accepts the request, hands off to Celery, and hands you back a task ID. The worker chews through the document in the background, writes the result to Redis, and you come back to &lt;code&gt;/tasks/{id}&lt;/code&gt; when you want it. No waiting thirty seconds on an HTTP connection while a scanned invoice is being OCR'd. No timeouts. No retry headaches.&lt;/p&gt;

&lt;p&gt;A minimal request looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# kick off a conversion&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://docling.yourdomain.com/convert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: your-token"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"url": "https://arxiv.org/pdf/2408.09869"}'&lt;/span&gt;

&lt;span class="c"&gt;# come back later for the result&lt;/span&gt;
curl https://docling.yourdomain.com/tasks/TASK_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: your-token"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole surface area you need on the calling side. The AI project does not care that there is a GPU, an OCR engine, six model files, and a CUDA runtime behind that URL. It just asks, and markdown comes back.&lt;/p&gt;

&lt;p&gt;Has your team also had this moment — where you realised the clean thing was to hide all the mess behind a single HTTP call and move on? I feel like half the services I build end up being exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CUDA-in-Docker saga
&lt;/h2&gt;

&lt;p&gt;I will not pretend the setup was smooth. The part that ate the most time was getting Docker to actually see the GPU.&lt;/p&gt;

&lt;p&gt;CUDA on bare metal is one thing. CUDA inside a Docker container using a GPU that belongs to the host is a different level of patience. I honestly do not remember every exact error message I ran into — this was some time ago and I have done a decent job of repressing it — but I remember the shape of the problem. The container would happily start. The Python process inside would happily import torch. And then torch would calmly tell me there were zero CUDA devices available, thank you very much.&lt;/p&gt;

&lt;p&gt;If you have ever been through this, you know the drill. Is the nvidia driver installed on the host? Yes. Is the container toolkit installed? Yes. Is the runtime configured in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;? Pretty sure yes. Is the compose file using &lt;code&gt;runtime: nvidia&lt;/code&gt; or the new &lt;code&gt;deploy.resources.reservations.devices&lt;/code&gt; block? Which one is the right one this year? Who knows anymore.&lt;/p&gt;

&lt;p&gt;I got it working. I got it working the way most of us get these things working — by reading five GitHub issues, trying four combinations, and eventually landing on the one that did not throw. The annoying bit is that once it works, it just keeps working, and you forget exactly which of the four things was the actual fix. If you are setting this up on your own GPU box, budget a bit of time for this specifically. It is not docling's fault. It is the price of doing business with NVIDIA and Docker in the same sentence.&lt;/p&gt;

&lt;p&gt;The moment the first OCR conversion came back and I saw the tables from a scanned document rendered as clean markdown — that was a good moment. Small win. But the kind of small win that makes a whole evening of driver wrangling feel worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the AI project actually gets
&lt;/h2&gt;

&lt;p&gt;From the app's point of view, the contract is dumb simple. Upload a file, or point at a URL. Poll a task. Get markdown. Feed it into whatever downstream chain was waiting for clean text.&lt;/p&gt;

&lt;p&gt;A few things that turned out to matter more than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Batch endpoint.&lt;/strong&gt; Users rarely upload one file. They upload a folder. &lt;code&gt;/convert/batch&lt;/code&gt; lets the worker pool chew through them in parallel instead of us queueing sequentially from the app side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddings on the same box.&lt;/strong&gt; Since we already had the GPU warm, generating vector embeddings right there saved an entire network hop. The app gets text &lt;em&gt;and&lt;/em&gt; vectors from one call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API key + rate limits at nginx.&lt;/strong&gt; It is an internal service, but internal services have a way of getting called from places you did not plan. A token and a per-IP rate limit at the edge cost almost nothing to set up and save a lot of explaining later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flower on localhost only.&lt;/strong&gt; If a Celery worker is stuck, you want to know. If a Flower dashboard is accidentally open on the public internet, you really do not want that. Binding it to localhost and tunnelling over SSH when I need it is the lazy, correct answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is clever. Most of it is the boring scaffolding you end up writing for any long-running task service. The good part is that once it is all in one repo with a &lt;code&gt;make init&lt;/code&gt;, the next person who needs a document processing endpoint can stand it up on their own GPU box without redoing any of the thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is this for
&lt;/h2&gt;

&lt;p&gt;Honestly, future me. The next time I need docling behind an API, I am not redoing the Celery setup and the nginx config from scratch.&lt;/p&gt;

&lt;p&gt;But also anyone else going down this path. If you are trying to get docling into a production shape — on your own hardware, with GPU, with OCR, with a queue, with TLS — and you do not want to glue it together from six different blog posts, this repo is pretty much that glue already. Not polished. Not fancy. But it works, and it is documented enough that you are not guessing what &lt;code&gt;make init&lt;/code&gt; is about to do.&lt;/p&gt;

&lt;p&gt;The repo is at &lt;a href="https://github.com/vineethkrishnan/docling-server" rel="noopener noreferrer"&gt;github.com/vineethkrishnan/docling-server&lt;/a&gt;. The deployment doc has the actual commands. If you hit the CUDA-in-Docker thing too, you have my sympathy — we have all been there.&lt;/p&gt;

&lt;p&gt;So that is where I will stop. If you have a different way of doing this, or a cleaner trick for the GPU-in-Docker bit, 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>docling</category>
      <category>fastapi</category>
      <category>celery</category>
      <category>docker</category>
    </item>
  </channel>
</rss>
