<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Menshikov Vasil</title>
    <description>The latest articles on DEV Community by Menshikov Vasil (@mnvasil).</description>
    <link>https://dev.to/mnvasil</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4037861%2Fd49bfbb6-487f-4324-a1b9-7d674e86608e.png</url>
      <title>DEV Community: Menshikov Vasil</title>
      <link>https://dev.to/mnvasil</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mnvasil"/>
    <language>en</language>
    <item>
      <title>How I Cut Python Docker Image Size From 1.2 GB to a Lean, Secure Container</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Tue, 04 Aug 2026 08:24:46 +0000</pubDate>
      <link>https://dev.to/mnvasil/how-i-cut-python-docker-image-size-from-12-gb-to-a-lean-secure-container-7bj</link>
      <guid>https://dev.to/mnvasil/how-i-cut-python-docker-image-size-from-12-gb-to-a-lean-secure-container-7bj</guid>
      <description>&lt;p&gt;When we moved our tracking API to Kubernetes, the container turned out to be the weakest link in the whole thing. It was a 1.2 GB image that took two full minutes to rebuild on every code change and then, adding insult to injury, flat-out refused to run in our local k3d cluster. If you've ever wanted to reduce a Python Docker image size and wondered why your "working" image won't load into a cluster, this is the afternoon where I fixed all three problems at once - and stopped being quietly embarrassed by my own Dockerfile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bugged me for weeks
&lt;/h2&gt;

&lt;p&gt;Our service, &lt;code&gt;myapp&lt;/code&gt;, is a Python 3.12 and FastAPI HTTP API on port 8080 that talks to PostgreSQL. The first Dockerfile I ever wrote for it is the first Dockerfile anyone writes, and it technically works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /code&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; python -m uvicorn app.main:app --host 0.0.0.0 --port 8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the uncomfortable truth: every single line is a small mistake. The full-fat &lt;code&gt;python:3.12&lt;/code&gt; base is enormous. &lt;code&gt;COPY . .&lt;/code&gt; before &lt;code&gt;pip install&lt;/code&gt; means a one-character code edit invalidates the dependency layer and reinstalls the world. The shell-form &lt;code&gt;CMD&lt;/code&gt; quietly breaks signal handling. And it runs as root. I lived with all of this for longer than I'd like to admit, treating the slow rebuilds as just the cost of doing business, until I got fed up and went hunting for a canonical, production-minded reference. Someone had written up &lt;a href="https://dorokhovich.com/blog/local-k8s-containerizing-your-service?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-containerizing-your-service" rel="noopener noreferrer"&gt;a walkthrough on writing a small, fast, secure Dockerfile for a FastAPI service and loading it into k3d&lt;/a&gt;, and honestly it restructured how I think about every instruction in the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheapest 90% win: fix your layer order
&lt;/h2&gt;

&lt;p&gt;Each Dockerfile instruction is a layer, Docker caches them, and - this is the part that matters - &lt;a href="https://docs.docker.com/build/cache/" rel="noopener noreferrer"&gt;if a layer changes, every layer after it is invalidated too&lt;/a&gt;. I was editing application code dozens of times a day and almost never touching dependencies, yet my ordering forced a full &lt;code&gt;pip install&lt;/code&gt; on every build. The fix is embarrassingly simple: install what rarely changes &lt;em&gt;before&lt;/em&gt; copying what changes constantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt   &lt;span class="c"&gt;# heavy, rarely changes&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; ./app ./app                                      # light, changes often&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one reorder dropped my rebuild from around two minutes to a few seconds on a code-only change, because the dependency layer now comes straight from cache. It's the single most common Dockerfile anti-pattern in existence, and I'd been cheerfully living inside it for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multi-stage build
&lt;/h2&gt;

&lt;p&gt;The naive image ships everything into the final result - compilers, dev headers, pip caches - all of it dead weight at runtime. &lt;a href="https://docs.docker.com/build/building/multi-stage/" rel="noopener noreferrer"&gt;A multi-stage build&lt;/a&gt; installs dependencies in a &lt;code&gt;build&lt;/code&gt; stage and copies only the finished virtualenv into a clean runtime image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# --- Stage 1: build ---&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.12-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /code&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv /opt/venv
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PATH="/opt/venv/bin:$PATH"&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# --- Stage 2: runtime ---&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH="/opt/venv/bin:$PATH"&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /code&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /opt/venv /opt/venv&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; ./app ./app&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["fastapi", "run", "app/main.py", "--port", "8080"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How much you save depends entirely on your dependencies - with pure wheels the gain is modest, but we had a couple of packages that compiled from source, and dropping the build toolchain shrank the image substantially. Switching from &lt;code&gt;python:3.12&lt;/code&gt; to &lt;code&gt;python:3.12-slim&lt;/code&gt; did the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small details that make it production-grade
&lt;/h2&gt;

&lt;p&gt;The write-up drilled a handful of these in, and every one of them has bitten someone I know. Use &lt;code&gt;fastapi run&lt;/code&gt;, not a bare &lt;code&gt;uvicorn --reload&lt;/code&gt; - the reload flag is dev-only overhead in a cluster image, and &lt;a href="https://fastapi.tiangolo.com/deployment/docker/" rel="noopener noreferrer"&gt;FastAPI's own container guide&lt;/a&gt; uses &lt;code&gt;fastapi run&lt;/code&gt;, which starts Uvicorn with sane production settings. Write &lt;code&gt;CMD&lt;/code&gt; in exec form, as an array, so the app becomes PID 1 and receives &lt;code&gt;SIGTERM&lt;/code&gt; from Kubernetes directly and shuts down gracefully; shell form wraps it in &lt;code&gt;/bin/sh&lt;/code&gt;, the signal never arrives, and the Pod gets hard-killed on timeout. Set &lt;code&gt;PYTHONUNBUFFERED=1&lt;/code&gt;, or your logs get stuck in a buffer and never surface in &lt;code&gt;kubectl logs&lt;/code&gt; - I lost an hour to "why is my container silent" before I understood that one. Add an unprivileged user with &lt;code&gt;adduser --disabled-password --uid 10001 appuser&lt;/code&gt; and then &lt;code&gt;USER appuser&lt;/code&gt;, because root-by-default violates least privilege and it pairs later with &lt;code&gt;securityContext.runAsNonRoot: true&lt;/code&gt; in the manifest. And add a &lt;code&gt;.dockerignore&lt;/code&gt;, because without one I was shipping &lt;code&gt;.git&lt;/code&gt;, a local &lt;code&gt;.venv&lt;/code&gt;, and nearly a &lt;code&gt;.env&lt;/code&gt; straight into the build context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that stole a full day: ImagePullBackOff
&lt;/h2&gt;

&lt;p&gt;With a beautiful new image built, I ran &lt;code&gt;docker build&lt;/code&gt;, applied the manifest, and the Pod sat in &lt;code&gt;ImagePullBackOff&lt;/code&gt; forever. It &lt;em&gt;seems&lt;/em&gt; completely obvious that a freshly built local image would be visible to the cluster. It is not, and this cost me a full day of my life. &lt;strong&gt;k3d nodes run their own containerd, isolated from your Docker daemon&lt;/strong&gt; - an image sitting in Docker is invisible to the cluster, and the kubelet just keeps failing to pull it from a remote registry that doesn't have it.&lt;/p&gt;

&lt;p&gt;The fastest fix for a one-off test is a direct import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; myapp:dev &lt;span class="nb"&gt;.&lt;/span&gt;
k3d image import myapp:dev &lt;span class="nt"&gt;-c&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair that with &lt;code&gt;imagePullPolicy: IfNotPresent&lt;/code&gt; in the manifest so Kubernetes doesn't reach for the network anyway. For ongoing work we moved to k3d's built-in registry, where the same name &lt;code&gt;k3d-registry.localhost:5000/myapp:dev&lt;/code&gt; works for both &lt;code&gt;push&lt;/code&gt; from the host and &lt;code&gt;pull&lt;/code&gt; from inside the cluster. The full delivery model - both paths, plus why &lt;code&gt;*.localhost&lt;/code&gt; resolves - is covered in the companion write-up linked in Sources below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more touches that paid off
&lt;/h2&gt;

&lt;p&gt;First, a &lt;code&gt;HEALTHCHECK&lt;/code&gt; that doesn't need curl. I wanted Docker to know whether the service was alive during local runs, but &lt;code&gt;python:3.12-slim&lt;/code&gt; has no &lt;code&gt;curl&lt;/code&gt;, and neither does distroless. So I did the check with Python, which is guaranteed to be in the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=10s --start-period=60s --retries=3 \&lt;/span&gt;
  CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/healthz').getcode()==200 else 1)"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, actually understanding what &lt;code&gt;EXPOSE&lt;/code&gt; does and doesn't do. For an embarrassingly long time I thought &lt;code&gt;EXPOSE 8080&lt;/code&gt; published the port. It doesn't - &lt;a href="https://docs.docker.com/reference/dockerfile/" rel="noopener noreferrer"&gt;the Dockerfile reference is explicit that &lt;code&gt;EXPOSE&lt;/code&gt; doesn't actually publish anything&lt;/a&gt;; it's pure metadata, documentation for whoever reads the file. Real publishing happens with &lt;code&gt;-p&lt;/code&gt; in &lt;code&gt;docker run&lt;/code&gt;, or with Service and Ingress objects in Kubernetes. Internalizing that killed a whole afternoon of "why can't I reach the port" confusion, purely because I stopped expecting &lt;code&gt;EXPOSE&lt;/code&gt; to do a job it was never designed for.&lt;/p&gt;

&lt;p&gt;I also standardized on running a single worker per Pod and scaling with replicas, rather than cramming Gunicorn plus multiple Uvicorn workers into one container. In Kubernetes the cluster &lt;em&gt;is&lt;/em&gt; the process manager, and letting it own concurrency kept my image simpler and my resource limits meaningful. For local development it's not even a question - one process is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it feels now
&lt;/h2&gt;

&lt;p&gt;The difference is night and day, and it's satisfying in a way that's hard to overstate. The base went from the full &lt;code&gt;python:3.12&lt;/code&gt; to a multi-stage &lt;code&gt;python:3.12-slim&lt;/code&gt;. A rebuild on a code change went from roughly two minutes of full &lt;code&gt;pip install&lt;/code&gt; to a few seconds served from cache. The container runs as uid 10001 &lt;code&gt;appuser&lt;/code&gt; instead of root. Signal handling went from broken - the shell-form &lt;code&gt;CMD&lt;/code&gt; swallowing &lt;code&gt;SIGTERM&lt;/code&gt; - to correct, with the app as PID 1 receiving signals directly. And the image, which used to greet me with &lt;code&gt;ImagePullBackOff&lt;/code&gt; every time, now imports and pushes into k3d cleanly.&lt;/p&gt;

&lt;p&gt;For local dev I deliberately stayed on &lt;code&gt;slim&lt;/code&gt;, because it's genuinely easy to debug. When I hardened the image for production later, I moved the runtime to &lt;a href="https://github.com/GoogleContainerTools/distroless" rel="noopener noreferrer"&gt;distroless&lt;/a&gt; (&lt;code&gt;gcr.io/distroless/python3-debian12&lt;/code&gt;) - no shell, no package manager, far fewer CVEs - building the deps on slim and copying them across. The trade-off is real: &lt;code&gt;docker exec ... sh&lt;/code&gt; no longer works, so you debug with &lt;code&gt;kubectl debug&lt;/code&gt; and ephemeral containers instead. That's a fair price for the reduced attack surface, but it's a choice worth making consciously rather than by accident.&lt;/p&gt;

&lt;p&gt;What stuck with me most is how tangled up all of this had felt when it was really one thing. I'd been treating "make it smaller," "make it faster," "make it secure," and "make it actually run in the cluster" as four separate chores I'd get to someday. They were never four projects. They were one Dockerfile, done with a little more care than the copy-paste version I'd been shrugging past for months - and the version of me who kept restarting slow builds would not believe how good a well-ordered Dockerfile feels to live with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker docs — &lt;a href="https://docs.docker.com/build/building/multi-stage/" rel="noopener noreferrer"&gt;Multi-stage builds&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker docs — &lt;a href="https://docs.docker.com/build/cache/" rel="noopener noreferrer"&gt;Optimizing builds with cache (layer invalidation)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker docs — &lt;a href="https://docs.docker.com/reference/dockerfile/" rel="noopener noreferrer"&gt;Dockerfile reference: EXPOSE and CMD exec form&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GoogleContainerTools/distroless — &lt;a href="https://github.com/GoogleContainerTools/distroless" rel="noopener noreferrer"&gt;Minimal images with no shell or package manager&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;FastAPI docs — &lt;a href="https://fastapi.tiangolo.com/deployment/docker/" rel="noopener noreferrer"&gt;FastAPI in Containers - Docker&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/local-k8s-containerizing-your-service?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-containerizing-your-service" rel="noopener noreferrer"&gt;A local-Kubernetes containerization write-up someone put together&lt;/a&gt;, with the complete annotated Dockerfile and the slim-vs-alpine-vs-distroless decision&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>kubernetes</category>
      <category>python</category>
    </item>
    <item>
      <title>How a Test Coverage Ratchet Finally Fixed the Codebase Everyone Was Afraid to Touch</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:25:03 +0000</pubDate>
      <link>https://dev.to/mnvasil/how-a-test-coverage-ratchet-finally-fixed-the-codebase-everyone-was-afraid-to-touch-2pb5</link>
      <guid>https://dev.to/mnvasil/how-a-test-coverage-ratchet-finally-fixed-the-codebase-everyone-was-afraid-to-touch-2pb5</guid>
      <description>&lt;p&gt;I have never seen a metric sit as stubbornly still as our test coverage did. Twenty-five percent. For two years. It wasn't drifting up, it wasn't drifting down, it just sat there like furniture nobody wanted to move. And the thing that eventually fixed it wasn't a rewrite or a hero week - it was a small, almost boring idea called a test coverage ratchet, which I'll get to. First I want to tell you why this bugged me for so long, because the psychology turned out to matter more than the config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bugged me for years
&lt;/h2&gt;

&lt;p&gt;I joined a team maintaining a ~400,000-line JavaScript and TypeScript monolith. Coverage was 25% and everyone knew it. It's not that people didn't care - I watched them care, out loud, in retros. It's that every attempt to fix it failed the exact same way. Someone would pitch a "refactoring sprint," leadership would grudgingly hand over two weeks, the team would rewrite one module, a production incident would eat half the time, and the whole thing would quietly evaporate. Coverage: still 25%.&lt;/p&gt;

&lt;p&gt;What really got under my skin was watching genuinely excellent engineers make a one-line change to a function they clearly understood, and then refuse to clean up the obvious mess sitting right next to it. When I asked why, the answers were always the same flavor: "it's always worked this way," "better not touch it," "honestly it'd be easier to rewrite than to understand." That's not laziness. Once I stopped reading it as laziness, everything reframed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I finally understood: it's fear, not incompetence
&lt;/h2&gt;

&lt;p&gt;Teams don't rot from a lack of knowledge. They rot from fear. When a codebase is big and fragile, every change &lt;em&gt;feels&lt;/em&gt; dangerous, so people start programming defensively - the smallest possible edit, a workaround instead of a fix. And it feeds on itself: the worse the code gets, the less anyone wants to touch it, which makes it worse. Psychologists have a name for it, learned helplessness, that state where you stop trying to change a situation even when you actually could. Michael Feathers puts the technical half of it bluntly in &lt;em&gt;Working Effectively with Legacy Code&lt;/em&gt; - &lt;a href="https://understandlegacycode.com/blog/key-points-of-working-effectively-with-legacy-code/" rel="noopener noreferrer"&gt;"legacy code is simply code without tests"&lt;/a&gt; - which is exactly why the fear is rational. With no tests, every edit really is a gamble.&lt;/p&gt;

&lt;p&gt;And here's the thing that clicked for me: you cannot fix learned helplessness with a two-week sprint. A sprint says "all or nothing," and since "all" is impossible, the brain quietly hears "nothing." The way out is the opposite of a sprint - tiny, achievable, basically-guaranteed-to-succeed steps. That's the whole spirit of incremental constraints, and it lines up perfectly with Robert C. Martin's &lt;a href="https://www.informit.com/articles/article.aspx?p=1235624&amp;amp;seqNum=6" rel="noopener noreferrer"&gt;Boy Scout Rule&lt;/a&gt;: &lt;strong&gt;leave every file you touch a little better than you found it.&lt;/strong&gt; Not perfect. A little better. Rename one variable, split one bloated function, delete one bit of duplication.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule we actually wrote down
&lt;/h2&gt;

&lt;p&gt;We put one sentence on the team wiki: &lt;em&gt;every change should leave the code in a better state than before the change.&lt;/em&gt; Then we made it concrete. New files had to meet a real bar - tests for public methods, no exceptions. Modified files couldn't lose coverage, and ideally gained a few points. Critical bug fixes shipped with a regression test that reproduced the bug. Refactors shipped with a test proving behavior hadn't changed.&lt;/p&gt;

&lt;p&gt;The magic is entirely in the asymmetry. We never asked anyone to go improve the 300,000 lines of legacy code. We asked only that whatever you &lt;em&gt;newly wrote or happened to touch that day&lt;/em&gt; met the bar. Legacy code you never open never blocks you. That one boundary is what made the whole thing feel possible instead of doomed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it real: the ratchet lives in CI
&lt;/h2&gt;

&lt;p&gt;A rule nobody enforces is just a nice feeling on a wiki. So we encoded the ratchet in three layers.&lt;/p&gt;

&lt;p&gt;The first is a two-tier jest coverage threshold. The trick is a per-path override - jest's &lt;a href="https://jestjs.io/docs/configuration#coveragethreshold-object" rel="noopener noreferrer"&gt;&lt;code&gt;coverageThreshold&lt;/code&gt; takes both a &lt;code&gt;global&lt;/code&gt; block and path/glob-specific blocks&lt;/a&gt;, and a glob's files are held to their own bar independently of the global one. We pinned the global numbers at &lt;em&gt;today's&lt;/em&gt; levels so they could never slide backward, while new feature directories answered to 80%:&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="c1"&gt;// jest.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;collectCoverageFrom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/**/*.{js,ts,tsx}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;coverageThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// current level - never decrease&lt;/span&gt;
      &lt;span class="na"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;statements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// Everything created after we flipped the switch&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/features/**/*.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;statements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&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;Every few weeks, once the global numbers had drifted upward on their own, we'd bump the &lt;code&gt;global&lt;/code&gt; floor up to meet them. That's the whole idea of a ratchet - it only ever clicks in one direction, and it can't click back.&lt;/p&gt;

&lt;p&gt;The second layer is a quality gate that judges only the diff. This pattern has a name too: &lt;strong&gt;diff coverage&lt;/strong&gt;, sometimes called patch coverage. You hold a high bar only to the lines a pull request adds or changes, and you cheerfully ignore the legacy sea around them. Codecov ships this as a first-class check - &lt;a href="https://docs.codecov.com/docs/commit-status" rel="noopener noreferrer"&gt;its &lt;code&gt;codecov/patch&lt;/code&gt; status "only measures lines adjusted in the pull request"&lt;/a&gt; - so you can demand 80% on new lines without touching the rest of the repo. Ready-made ratchet tools exist as well; &lt;a href="https://github.com/Koleok/jest-coverage-ratchet" rel="noopener noreferrer"&gt;&lt;code&gt;jest-coverage-ratchet&lt;/code&gt;&lt;/a&gt; reads your coverage summary and nudges each threshold up to the current level so it can only ever go higher. All of these are lovely. But a tool with no culture behind it just becomes another gate people learn to game, so we wrote a thin wrapper of our own to keep the numbers legible to the team:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/quality-gate.yml&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;Quality Gate&lt;/span&gt;
&lt;span class="na"&gt;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;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;quality-check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;  &lt;span class="c1"&gt;# needed for diff analysis&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;Check coverage for changed files&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;CHANGED_FILES=$(git diff --name-only origin/main...HEAD | grep -E '\.(js|ts|tsx)$')&lt;/span&gt;
          &lt;span class="s"&gt;if [ ! -z "$CHANGED_FILES" ]; then&lt;/span&gt;
            &lt;span class="s"&gt;npm run test:coverage&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We rolled it out with a phase I'd call "measurement without judgment" - run coverage, ESLint, and duplication analysis purely to see where we stood, with zero blame attached to any number. That framing mattered enormously. Nobody feels attacked by a dashboard they helped set up.&lt;/p&gt;

&lt;p&gt;The third layer is ESLint with the same asymmetry - strict complexity limits on new directories, warnings-only on the legacy ones, so &lt;code&gt;src/features/**&lt;/code&gt; answered to a complexity ceiling of 8 and 30-line functions, while &lt;code&gt;src/legacy/**&lt;/code&gt; got gentle warnings at 15 and 100. Same shape, different tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick that unlocked the genuinely scary files
&lt;/h2&gt;

&lt;p&gt;Some functions were terrifying - a 200-line &lt;code&gt;calculateDiscount&lt;/code&gt; nobody fully understood. You can't refactor what you can't describe, and this is where Feathers' &lt;em&gt;characterization test&lt;/em&gt; earns its keep: &lt;a href="https://understandlegacycode.com/blog/key-points-of-working-effectively-with-legacy-code/" rel="noopener noreferrer"&gt;it "characterizes the actual behavior of a piece of code"&lt;/a&gt;. You don't test what the code &lt;em&gt;should&lt;/em&gt; do. You test what it &lt;em&gt;currently&lt;/em&gt; does, whatever that is, warts and all, and pin it in place before you dare touch anything:&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="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;calculateDiscount - current behavior&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VIP user with promo XYZ123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateDiscount&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VIP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XYZ123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// We don't know WHY it's 0.25, we're just pinning current behavior&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.25&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;The emotional shift here is real. Once a tangled function is wrapped in characterization tests, it just stops being scary. You've got a net that screams the instant behavior changes, so you can finally carve it into small, testable pieces with your shoulders down.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it feels now
&lt;/h2&gt;

&lt;p&gt;Eleven months later, global line coverage had climbed from 25% to 61%, and the code we shipped that quarter was sitting around 84% - up from maybe 10% before. We reverted far fewer PRs for regressions, and "safe-ing" a scary legacy function went from a thing we simply avoided to something you could knock out in an afternoon. Most tellingly, the number of engineers willing to touch &lt;code&gt;calculateDiscount&lt;/code&gt; went from exactly one to most of the team.&lt;/p&gt;

&lt;p&gt;But the number I care about most is the one we never scheduled: zero dedicated refactoring sprints. Coverage climbed because ordinary feature work now dragged quality up with it, one touched file at a time, and nobody had to be a hero.&lt;/p&gt;

&lt;p&gt;We hit a few walls worth naming. Don't set the new-code bar at 100% - we tried 90% and people gamed it with trivial assertions; 80% forced real tests without inviting malicious compliance. Bump the global floor by hand, in its own PR, on purpose - we automated it once and a hot-fix that happened to touch a well-covered file caused flaky failures. And treat "measurement without judgment" as load-bearing: the first time a manager used the coverage dashboard to single someone out in a review, trust cratered for a month. Kill that instinct early and loudly.&lt;/p&gt;

&lt;p&gt;If you want the long version - the full psychology, every config, the pre-commit hooks I didn't have room for - &lt;a href="https://dorokhovich.com/blog/incremental-constraints?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=incremental-constraints" rel="noopener noreferrer"&gt;someone wrote up the whole incremental-constraints playbook here&lt;/a&gt;, and it's worth a read before you pitch your next doomed sprint.&lt;/p&gt;

&lt;p&gt;What stays with me isn't the graph. It's that "better not touch it" has basically vanished from our standups. We didn't make anyone braver by asking them to be brave. We just made the next small step safe enough that bravery stopped being the requirement - and it turns out an entire team quietly leaving files a little better than they found them will outrun any refactoring sprint you could ever schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://jestjs.io/docs/configuration#coveragethreshold-object" rel="noopener noreferrer"&gt;Configuring Jest — &lt;code&gt;coverageThreshold&lt;/code&gt; with global and per-glob overrides&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.informit.com/articles/article.aspx?p=1235624&amp;amp;seqNum=6" rel="noopener noreferrer"&gt;Robert C. Martin — The Boy Scout Rule (InformIT)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://understandlegacycode.com/blog/key-points-of-working-effectively-with-legacy-code/" rel="noopener noreferrer"&gt;Michael Feathers, Working Effectively with Legacy Code — characterization tests, summarized&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.codecov.com/docs/commit-status" rel="noopener noreferrer"&gt;Codecov — Status Checks and the &lt;code&gt;codecov/patch&lt;/code&gt; (diff) coverage gate&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Koleok/jest-coverage-ratchet" rel="noopener noreferrer"&gt;jest-coverage-ratchet — a ready-made one-directional coverage ratchet (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/incremental-constraints?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=incremental-constraints" rel="noopener noreferrer"&gt;A full write-up of one team's rollout, with the configs and the culture change laid out&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>softwareengineering</category>
      <category>testing</category>
      <category>typescript</category>
    </item>
    <item>
      <title>The brew services Setup That Finally Got Docker Desktop Off My Mac (and Made Local Postgres Feel Instant)</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Sun, 02 Aug 2026 08:39:15 +0000</pubDate>
      <link>https://dev.to/mnvasil/the-brew-services-setup-that-finally-got-docker-desktop-off-my-mac-and-made-local-postgres-feel-1dlj</link>
      <guid>https://dev.to/mnvasil/the-brew-services-setup-that-finally-got-docker-desktop-off-my-mac-and-made-local-postgres-feel-1dlj</guid>
      <description>&lt;p&gt;For a long time the first fifteen minutes of my workday belonged to a whale. I'd open the laptop, hear the fans spin up, watch "Docker Desktop is updating," and wait - all so I could run a single local Postgres container that my Mac was perfectly capable of running on its own. The morning three people on my team showed up late to standup for the exact same reason ("my fans won't stop and Postgres won't bind," "I ran &lt;code&gt;docker compose up&lt;/code&gt; and my battery's already at 40%"), something in me finally snapped. This is the &lt;code&gt;brew services&lt;/code&gt; setup that got Docker Desktop off our machines, and honestly, made local development feel joyful again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bugged me for years
&lt;/h2&gt;

&lt;p&gt;Our local stack was not exotic. One &lt;code&gt;docker-compose.yml&lt;/code&gt; with Postgres, Redis, and MySQL. That's it. And yet Docker Desktop's VM taxed every machine the entire day - idle RAM I could feel, CPU burned on file-sync I never asked for, a licensing question hanging over the whole thing, and that update nag that always seemed to block me at the worst possible moment.&lt;/p&gt;

&lt;p&gt;The licensing part had teeth, too. Under the &lt;a href="https://www.docker.com/legal/docker-subscription-service-agreement/" rel="noopener noreferrer"&gt;Docker Subscription Service Agreement&lt;/a&gt;, Docker Desktop needs a paid subscription once your company is big enough - the free tier caps out under 250 employees and $10M revenue. We were over one of those lines, which meant Docker Desktop wasn't only a performance tax, it was a per-seat bill for the privilege of running one Postgres. That combination gnawed at me for a genuinely embarrassing amount of time before I did anything about it.&lt;/p&gt;

&lt;p&gt;What finally reframed it was saying the obvious thing out loud: on a Mac, you already own a first-class service supervisor. It's called &lt;code&gt;launchd&lt;/code&gt;, it's what Apple uses to run its own daemons, and Homebrew speaks it fluently. I'd been renting a Linux VM to babysit a database my operating system already knew how to babysit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why brew services instead of a container
&lt;/h2&gt;

&lt;p&gt;The pitch is almost too simple. Homebrew installs Postgres, Redis, MySQL, MongoDB, Nginx, RabbitMQ, Elasticsearch, Kafka - the whole cast of local dev dependencies - as native formulae. Then &lt;code&gt;brew services&lt;/code&gt; wraps macOS's own &lt;code&gt;launchd&lt;/code&gt; so those processes start at login, get supervised, and stop cleanly (&lt;a href="https://docs.brew.sh/Manpage" rel="noopener noreferrer"&gt;the full command surface lives in the Homebrew manpage&lt;/a&gt;). No VM. No file-sync layer. No 4GB toll booth in front of a single database.&lt;/p&gt;

&lt;p&gt;I want to be honest about the trade-off, because I treated this as a decision and not a religion. Containers give you isolation, reproducibility, multiple versions side by side, and real production parity. Homebrew gives you native performance, near-zero overhead, and a Mac-first experience for the ninety-percent case where you just need &lt;em&gt;a&lt;/em&gt; Postgres to develop against. Both of those are true at once. If you want the longer version of that decision, with all the commands and the honest tradeoffs laid out, &lt;a href="https://dorokhovich.com/blog/homebrew-services?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=homebrew-services" rel="noopener noreferrer"&gt;someone wrote up the whole Homebrew-vs-containers case here&lt;/a&gt; and it's a good companion read.&lt;/p&gt;

&lt;p&gt;And yes, I did the responsible thing and evaluated the usual Docker Desktop alternatives first. &lt;a href="https://github.com/abiosoft/colima" rel="noopener noreferrer"&gt;Colima&lt;/a&gt;, OrbStack, and Podman are all genuinely lighter than Docker Desktop and worth knowing - Colima especially gives you a Docker-compatible runtime with minimal fuss. But they're still &lt;em&gt;containers&lt;/em&gt;. They shave the licensing and some of the RAM; they don't change the fundamental fact that you're running a Linux VM to host a database your Mac can run natively. For a single local Postgres or Redis, &lt;code&gt;brew services&lt;/code&gt; isn't a lighter container runtime. It's &lt;em&gt;no container at all&lt;/em&gt; - which is the part most "Docker Desktop alternatives 2025" roundups quietly skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: four commands and a launchd file
&lt;/h2&gt;

&lt;p&gt;Here's the entire day-to-day surface. Four commands:&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;# Start a service&lt;/span&gt;
brew services start postgresql

&lt;span class="c"&gt;# Stop a service&lt;/span&gt;
brew services stop postgresql

&lt;span class="c"&gt;# Restart a service&lt;/span&gt;
brew services restart postgresql

&lt;span class="c"&gt;# List all services and their status&lt;/span&gt;
brew services list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is the command I actually live in. It hands you an at-a-glance view of what's running and where its config lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;brew services list
&lt;span class="go"&gt;Name       Status  User    Plist
mysql      started username /Users/username/Library/LaunchAgents/homebrew.mxcl.mysql.plist
postgresql stopped
redis      started username /Users/username/Library/LaunchAgents/homebrew.mxcl.redis.plist
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our old onboarding doc had a two-page section titled "Installing and troubleshooting Docker Desktop." The new version is a shell snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;postgresql@16 redis mysql
brew services start postgresql@16
brew services start redis
brew services start mysql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new hire now goes from a fresh laptop to a working database stack in about the time it takes to fetch coffee. No account, no login, no license-acceptance dialog. The first time I watched someone new run that snippet and just... have a database, I felt a little pang about all the mornings I'd lost to the whale.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's actually happening underneath
&lt;/h3&gt;

&lt;p&gt;I didn't want to hand anyone a magic command they couldn't reason about, so we documented the mechanism. When you run &lt;code&gt;brew services start postgresql&lt;/code&gt;, Homebrew generates and registers a &lt;code&gt;.plist&lt;/code&gt; with &lt;code&gt;launchd&lt;/code&gt; - the same supervision layer Apple uses for system daemons. Apple's &lt;a href="https://developer.apple.com/library/archive/technotes/tn2083/_index.html" rel="noopener noreferrer"&gt;Technical Note TN2083 on Daemons and Agents&lt;/a&gt; is the canonical reference for how login agents like this get loaded, and &lt;a href="https://thoughtbot.com/blog/starting-and-stopping-background-services-with-homebrew" rel="noopener noreferrer"&gt;thoughtbot's walkthrough of starting and stopping services with Homebrew&lt;/a&gt; is a friendly plain-English companion. The generated plist is refreshingly readable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;plist&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Label&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;homebrew.mxcl.postgresql&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/usr/local/opt/postgresql/bin/postgres&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;-D&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/usr/local/var/postgres&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;RunAtLoad&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;KeepAlive&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardErrorPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/usr/local/var/log/postgres.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plist&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RunAtLoad&lt;/code&gt; starts it at login. &lt;code&gt;KeepAlive&lt;/code&gt; restarts it if it dies. Logs land at a predictable path you can just &lt;code&gt;tail&lt;/code&gt;. Once people saw that this was only &lt;code&gt;launchd&lt;/code&gt; doing what it already does for the whole OS, the "but is it reliable?" worries quietly dissolved.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it feels now
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend "trust me, it feels faster" is data, so we did measure it across a handful of volunteer machines for a couple of weeks. The headline is the memory. The local database stack went from something like 4GB of idle RAM under Docker Desktop to under 200MB with &lt;code&gt;brew services&lt;/code&gt; - and getting roughly 4GB back on a 16GB MacBook is the difference between a laggy editor and a responsive one. Two people told me they'd stopped closing their browser to "make room" for the dev stack, which tells you everything about the world we'd been living in.&lt;/p&gt;

&lt;p&gt;The rest followed. Cold database start dropped from nearly half a minute to under two seconds. The fans, which used to be a constant background hum, became a rare event. Onboarding went from around fourteen steps to four. And the annual license-management chore simply vanished. None of these numbers are exotic; they're just what happens when you stop running a VM to do a job your OS does natively.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas nobody puts in the README
&lt;/h2&gt;

&lt;p&gt;It wasn't frictionless, and I'd be lying if I pretended otherwise. Version pinning is now on you - a container pins its version in the compose file, but Homebrew installs &lt;em&gt;a&lt;/em&gt; version and a stray &lt;code&gt;brew upgrade&lt;/code&gt; can move it out from under you mid-sprint. We standardized on the versioned formula, &lt;code&gt;postgresql@16&lt;/code&gt;, and left a note in the Brewfile so nobody accidentally jumps a major version.&lt;/p&gt;

&lt;p&gt;A service that "won't start" is almost always a stale socket or a log you haven't read yet. My debugging loop is boring and reliable: glance at &lt;code&gt;brew services list&lt;/code&gt;, then actually read the log, then poke the service itself rather than trusting the status column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew services list
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /usr/local/var/log/postgres.log
ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;postgres
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine times out of ten the log names the culprit outright - a leftover &lt;code&gt;postmaster.pid&lt;/code&gt;, a port already bound, a data dir from an older major version.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;sudo brew services&lt;/code&gt; trap is worth calling out too. Running a service as your user (a login agent) versus as root (a system daemon) puts the plist in different directories and changes when it starts; TN2083 above explains exactly why. For local dev you almost always want the user-level agent. We deleted one &lt;code&gt;sudo&lt;/code&gt; somebody had copy-pasted from a random blog and half of our "it doesn't start at boot" complaints evaporated. And finally: your data now lives on the host with no volume abstraction, which is simpler but means a careless &lt;code&gt;brew uninstall&lt;/code&gt; can take your local data with it. Our rule became a one-liner - your local DB is disposable, seed it from a script, never keep anything you can't regenerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Docker still wins, and why I kept it
&lt;/h2&gt;

&lt;p&gt;I did not declare war on Docker. I just retired Docker &lt;em&gt;Desktop as the default local-database runtime&lt;/em&gt;. The instant you need several services wired together for an integration test, or byte-for-byte production parity, containers are still exactly right. Our whole policy collapsed into one sentence: &lt;strong&gt;Homebrew for quick local databases, Docker for multi-service integration tests.&lt;/strong&gt; Both live in the repo; you reach for the lighter one by default. That single sentence ended months of "should we standardize on containers or not" bikeshedding, because it was never either/or - most jobs are just lighter than we'd assumed.&lt;/p&gt;

&lt;p&gt;We're tightening a couple of loose ends now, mostly a checked-in &lt;code&gt;Brewfile&lt;/code&gt; so &lt;code&gt;brew bundle&lt;/code&gt; reproduces the exact service set - and because &lt;a href="https://docs.brew.sh/Brew-Bundle-and-Brewfile" rel="noopener noreferrer"&gt;Homebrew Bundle can start services declaratively&lt;/a&gt; with &lt;code&gt;restart_service: true&lt;/code&gt;, that closes most of the reproducibility gap containers otherwise own.&lt;/p&gt;

&lt;p&gt;What stays with me, though, isn't the RAM graph. It's that my laptop is quiet in the morning now. There's a particular kind of craft in noticing the tool you've been tolerating for years and asking whether it's actually earning its keep - and if your machine sounds like a jet engine every morning to run one Postgres and a Redis, I'd gently suggest it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.brew.sh/Manpage" rel="noopener noreferrer"&gt;Homebrew Documentation — brew(1) Manpage (the full &lt;code&gt;brew services&lt;/code&gt; command reference)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.brew.sh/Brew-Bundle-and-Brewfile" rel="noopener noreferrer"&gt;Homebrew Bundle, brew bundle and Brewfile — declarative service management&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/library/archive/technotes/tn2083/_index.html" rel="noopener noreferrer"&gt;Apple Technical Note TN2083: Daemons and Agents — how launchd loads login agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/starting-and-stopping-background-services-with-homebrew" rel="noopener noreferrer"&gt;thoughtbot — Starting and stopping background services with Homebrew&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/abiosoft/colima" rel="noopener noreferrer"&gt;Colima — Docker-compatible container runtimes on macOS with minimal setup (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.docker.com/legal/docker-subscription-service-agreement/" rel="noopener noreferrer"&gt;Docker Subscription Service Agreement — the licensing terms worth checking against your headcount&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/homebrew-services?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=homebrew-services" rel="noopener noreferrer"&gt;A longer field-notes write-up on the Homebrew-vs-containers decision and the launchd internals&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>How to Generate requirements.txt From What Your Code Actually Imports (pipreqs vs pip freeze)</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Sat, 01 Aug 2026 16:15:03 +0000</pubDate>
      <link>https://dev.to/mnvasil/how-to-generate-requirementstxt-from-what-your-code-actually-imports-pipreqs-vs-pip-freeze-1gm1</link>
      <guid>https://dev.to/mnvasil/how-to-generate-requirementstxt-from-what-your-code-actually-imports-pipreqs-vs-pip-freeze-1gm1</guid>
      <description>&lt;p&gt;There's a file in most Python projects that everyone commits and nobody reads, and for two years ours was quietly lying to us. If you've ever wondered how to generate a &lt;code&gt;requirements.txt&lt;/code&gt; that reflects what your code actually needs - rather than every stray package that ever wandered into your virtualenv - this is the story of how I finally stopped trusting &lt;code&gt;pip freeze&lt;/code&gt; for that job and switched to &lt;code&gt;pipreqs&lt;/code&gt;. Our file went from 214 packages to 23, and honestly the bigger relief was emotional: I could finally open it and believe what it told me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bugged me for years
&lt;/h2&gt;

&lt;p&gt;Every Python engineer knows the ritual. Finish a feature, run &lt;code&gt;pip freeze &amp;gt; requirements.txt&lt;/code&gt;, commit, move on. It works. Right up until it doesn't.&lt;/p&gt;

&lt;p&gt;Ours had metastasized. A new hire opened a PR and asked the most innocent question in code review: &lt;em&gt;"Why does this API service depend on &lt;code&gt;jupyter&lt;/code&gt;, &lt;code&gt;matplotlib&lt;/code&gt;, and &lt;code&gt;black&lt;/code&gt;?"&lt;/em&gt; It didn't. Those were tools somebody had &lt;code&gt;pip install&lt;/code&gt;-ed into the shared venv months earlier and never removed. And &lt;code&gt;pip freeze&lt;/code&gt; does not care in the slightest what your code imports - it snapshots &lt;em&gt;everything installed in the environment&lt;/em&gt;. Our so-called source of truth was 90% noise, and I'd been squinting past it for so long I'd stopped seeing it.&lt;/p&gt;

&lt;p&gt;That noise was not harmless, which is what finally pushed me to act. Docker builds crawled, because all 214 packages got resolved and installed on every cold build. Our vulnerability scanner shrieked about CVEs in libraries we never once imported. And worst of all, nobody could tell which dependencies were real, so nobody dared delete any of them. That last one is the quiet killer - a file so untrustworthy that fear freezes it in place. (I've watched the exact same fear freeze legacy test suites, so I recognized the smell immediately.)&lt;/p&gt;

&lt;p&gt;So I went looking for a better way to generate the thing, and the tool I'd criminally underused was &lt;code&gt;pipreqs&lt;/code&gt;, whose one-line pitch in its &lt;a href="https://github.com/bndr/pipreqs" rel="noopener noreferrer"&gt;GitHub README&lt;/a&gt; is to "generate pip requirements.txt file based on imports of any project" - the exact opposite of what &lt;code&gt;pip freeze&lt;/code&gt; does.&lt;/p&gt;

&lt;h2&gt;
  
  
  In fairness to pip freeze
&lt;/h2&gt;

&lt;p&gt;I don't want to trash &lt;code&gt;pip freeze&lt;/code&gt;, because it isn't wrong - it's just answering a different question than I was asking. The &lt;a href="https://pip.pypa.io/en/stable/cli/pip_freeze/" rel="noopener noreferrer"&gt;official pip docs&lt;/a&gt; are refreshingly blunt about this: it "reports what is installed; it does not compute a lockfile or a solver result." It answers &lt;em&gt;"what is installed here,"&lt;/em&gt; not &lt;em&gt;"what does this code need."&lt;/em&gt; In a pristine, single-purpose virtualenv those two sets are identical. In a real, long-lived dev environment they drift apart embarrassingly fast.&lt;/p&gt;

&lt;p&gt;The canonical flow is exactly what you'd expect:&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;# macOS/Linux&lt;/span&gt;
&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your venv is clean and dedicated to one project, that's genuinely the right tool - it captures exact versions of everything, which is precisely what you want for a fully reproducible environment. Our problem was simply that our venv had become a junk drawer, and we'd been using a drawer-inventory tool to describe a shopping list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that finally clicked
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pipreqs&lt;/code&gt; takes the opposite approach, and once I understood it I felt a little silly for not switching sooner. It statically parses your &lt;code&gt;.py&lt;/code&gt; files, finds the actual &lt;code&gt;import&lt;/code&gt; statements, maps them to PyPI packages, and writes only those. Here's the whole sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. install it (once)&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;pipreqs

&lt;span class="c"&gt;# 2. from the project root, scan the code&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/your/project
pipreqs &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# 3. regenerating over an existing file? force it&lt;/span&gt;
pipreqs &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.&lt;/code&gt; is just the current directory - &lt;code&gt;pipreqs&lt;/code&gt; walks it, reads the imports, and emits a &lt;code&gt;requirements.txt&lt;/code&gt; containing only the libraries your code truly touches. The README documents the flags worth knowing: &lt;code&gt;--savepath&lt;/code&gt; to write elsewhere, &lt;code&gt;--print&lt;/code&gt; to dump to stdout, and &lt;code&gt;--diff&lt;/code&gt; to compare an existing file against the project's real imports. That's the entire change. The generated file went from 214 lines to 23 - the same 23 packages the app had been importing all along, now finally the only 23 in the file.&lt;/p&gt;

&lt;p&gt;The payoff rippled outward faster than I expected. The final Docker image roughly halved, dropping from around 1.3 GB to a little over 600 MB. A cold &lt;code&gt;pip install&lt;/code&gt; in CI went from nearly three minutes to under a minute. Our scanner's findings on dependencies fell from thirty-one to six, and every one of those six was now a package we actually used, which meant we could actually triage them. And for the first time, "can I delete this dependency?" became a question with an answer instead of a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where pipreqs bites (because it's not a silver bullet)
&lt;/h2&gt;

&lt;p&gt;I'd be a bad engineer if I sold you &lt;code&gt;pipreqs&lt;/code&gt; as pure upside, so here's where it drew blood - and the first one is a well-known limit of static analysis, not a bug. Dynamic and conditional imports simply get missed. If you reach for &lt;code&gt;importlib.import_module(name)&lt;/code&gt; or import inside a &lt;code&gt;try/except&lt;/code&gt;, &lt;code&gt;pipreqs&lt;/code&gt; never sees it. We had exactly one - a plugin loaded by string name - and it broke at runtime until we pinned it by hand.&lt;/p&gt;

&lt;p&gt;The other traps are gentler. Import name isn't always package name: &lt;code&gt;import cv2&lt;/code&gt; is really &lt;code&gt;opencv-python&lt;/code&gt;, &lt;code&gt;import yaml&lt;/code&gt; is &lt;code&gt;PyYAML&lt;/code&gt;. &lt;code&gt;pipreqs&lt;/code&gt; handles most of these through its mapping, but verify anything exotic. It infers versions from your environment or PyPI, so keep running it inside your activated venv to get versions that match what you actually tested against. And test/dev-only tools legitimately vanish from the output - which is the point, not a defect. If you &lt;em&gt;want&lt;/em&gt; &lt;code&gt;pytest&lt;/code&gt; and &lt;code&gt;black&lt;/code&gt; tracked, give them their own &lt;code&gt;requirements-dev.txt&lt;/code&gt; rather than mourning that &lt;code&gt;pipreqs&lt;/code&gt; dropped them.&lt;/p&gt;

&lt;p&gt;Our pragmatic landing spot: &lt;code&gt;pipreqs&lt;/code&gt; generates the runtime &lt;code&gt;requirements.txt&lt;/code&gt;, a hand-maintained &lt;code&gt;requirements-dev.txt&lt;/code&gt; holds the tooling, and CI diffs the generated file to catch drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out across twelve services without a big-bang
&lt;/h2&gt;

&lt;p&gt;I'm allergic to regenerating every manifest in one heroic PR and praying, because bloated dependency files are load-bearing in ways you only discover when something 404s in prod at 2am. So we staged it. For each service we first saved the old &lt;code&gt;pip freeze&lt;/code&gt; output as &lt;code&gt;requirements.legacy.txt&lt;/code&gt;, so if &lt;code&gt;pipreqs&lt;/code&gt; missed something we had the full list to diff against. Then we generated into a &lt;em&gt;separate&lt;/em&gt; file with &lt;code&gt;pipreqs . --force --savepath requirements.pipreqs.txt&lt;/code&gt; and reviewed what fell out by hand - ninety percent of the drops were obviously dev tooling, and the remaining ten percent got a human look. We rebuilt each image in a scratch container and ran the full suite plus a smoke test against the real boot path, not just unit tests, specifically to flush out the dynamic-import failures early. Then we promoted one service, let it bake in production for a week, and only then fanned out to the rest.&lt;/p&gt;

&lt;p&gt;Exactly one service broke, and it broke exactly where I'd feared: our notification worker loads channel plugins by string name via &lt;code&gt;importlib&lt;/code&gt;, so &lt;code&gt;pipreqs&lt;/code&gt; never saw &lt;code&gt;slack_sdk&lt;/code&gt; or &lt;code&gt;twilio&lt;/code&gt;. The scratch-container smoke test caught it before any customer did. We dropped those two into an explicit &lt;code&gt;requirements.extra.txt&lt;/code&gt; and concatenated it during the build. Cheap lesson, caught in the right place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reproducibility question everyone asks
&lt;/h2&gt;

&lt;p&gt;The most common objection I heard internally was fair: &lt;em&gt;"pip freeze guarantees exact versions and full transitive pinning - pipreqs only gives me top-level packages, isn't that less reproducible?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The honest answer is that the two tools solve different halves of the problem, and the Python community has since standardized that split. &lt;code&gt;pip freeze&lt;/code&gt; output plays the role of a &lt;strong&gt;lockfile&lt;/strong&gt; - the exact, fully-resolved state you deploy. As of &lt;a href="https://packaging.python.org/en/latest/specifications/pylock-toml/" rel="noopener noreferrer"&gt;PEP 751 and the &lt;code&gt;pylock.toml&lt;/code&gt; spec&lt;/a&gt;, a lockfile's entire job is "specifying dependencies to enable reproducible installation," pinning exact versions, URLs, and hashes. &lt;code&gt;pipreqs&lt;/code&gt; shines as the &lt;em&gt;other&lt;/em&gt; half - a &lt;strong&gt;manifest&lt;/strong&gt;, the human-readable statement of intent about what your code needs. Our whole mistake had been asking one file to do both jobs, and letting the lockfile role bloat the manifest into something unreadable. If you want the long version of that manifest-versus-lockfile distinction and the staged rollout, &lt;a href="https://dorokhovich.com/blog/generate-requirements-txt-python-project?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=generate-requirements-txt-python-project" rel="noopener noreferrer"&gt;someone wrote up the whole twelve-service migration here&lt;/a&gt; and it's a good deep-dive.&lt;/p&gt;

&lt;p&gt;Our fix keeps both files: &lt;code&gt;pipreqs&lt;/code&gt; writes the intent-level &lt;code&gt;requirements.txt&lt;/code&gt;, and we resolve and pin into a separate lockfile at build time. The manifest is readable and reviewable again, and reproducibility lives where it belongs. We wired a guard into CI too - regenerate the file, and fail the build if it drifts from what's committed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipreqs &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--savepath&lt;/span&gt; /tmp/req.check
diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sort &lt;/span&gt;requirements.txt&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sort&lt;/span&gt; /tmp/req.check&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a new import lands without updating the manifest, the diff is non-empty and the pipeline goes red. No more mystery dependencies sneaking in - or, more to the point, lingering unnoticed for two years.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it feels now
&lt;/h2&gt;

&lt;p&gt;We're piloting &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;&lt;code&gt;uv&lt;/code&gt;&lt;/a&gt;, Astral's Rust-based package manager, and weighing whether &lt;a href="https://packaging.python.org/en/latest/guides/writing-pyproject-toml/" rel="noopener noreferrer"&gt;&lt;code&gt;pyproject.toml&lt;/code&gt;&lt;/a&gt; should become the single source of dependency truth with &lt;code&gt;requirements.txt&lt;/code&gt; generated as a lockfile artifact - which is where most modern Python workflows are clearly heading. But I keep coming back to how small the actual fix was. One tool swap turned a 214-line liability into a 23-line file that documents exactly what the service needs, and that's it.&lt;/p&gt;

&lt;p&gt;What I didn't expect was the feeling. That file stopped being a place packages go to die and became something I actually read in review. If yours has that same graveyard smell, run &lt;code&gt;pipreqs .&lt;/code&gt; against your project right now and diff it against what's committed. I'd bet you'll be a little startled - and maybe a little relieved - at how little your code actually imports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/bndr/pipreqs" rel="noopener noreferrer"&gt;pipreqs&lt;/a&gt; — the tool itself, README and flags (&lt;code&gt;--savepath&lt;/code&gt;, &lt;code&gt;--print&lt;/code&gt;, &lt;code&gt;--diff&lt;/code&gt;, &lt;code&gt;--force&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pip.pypa.io/en/stable/cli/pip_freeze/" rel="noopener noreferrer"&gt;pip freeze — official pip documentation&lt;/a&gt; ("reports what is installed; it does not compute a lockfile")&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://packaging.python.org/en/latest/specifications/pylock-toml/" rel="noopener noreferrer"&gt;PEP 751 / &lt;code&gt;pylock.toml&lt;/code&gt; specification&lt;/a&gt; — the standardized lockfile format, Python Packaging User Guide&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://packaging.python.org/en/latest/guides/writing-pyproject-toml/" rel="noopener noreferrer"&gt;Writing your &lt;code&gt;pyproject.toml&lt;/code&gt;&lt;/a&gt; — Python Packaging User Guide&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt; — Astral's fast package/project manager with universal lockfiles&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/generate-requirements-txt-python-project?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=generate-requirements-txt-python-project" rel="noopener noreferrer"&gt;A full write-up of this migration someone put together&lt;/a&gt; — the staged rollout across twelve services, the CI drift check, and the manifest-vs-lockfile split&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>softwareengineering</category>
      <category>tools</category>
    </item>
    <item>
      <title>Hacker News + dev.to (thought-leadership framing of the inner-loop problem drives the whole funnel)</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Wed, 22 Jul 2026 08:48:27 +0000</pubDate>
      <link>https://dev.to/mnvasil/hacker-news-devto-thought-leadership-framing-of-the-inner-loop-problem-drives-the-whole-funnel-2jo8</link>
      <guid>https://dev.to/mnvasil/hacker-news-devto-thought-leadership-framing-of-the-inner-loop-problem-drives-the-whole-funnel-2jo8</guid>
      <description>&lt;h1&gt;
  
  
  The Kubernetes inner dev loop is slow AND lies to you — here's why
&lt;/h1&gt;

&lt;p&gt;If you've ever shipped a service to Kubernetes for the first time, you know the feeling: the edit→result loop that took a fraction of a second with &lt;code&gt;uvicorn --reload&lt;/code&gt; suddenly takes 2–5 minutes, and code that "worked locally" dies in the cluster for reasons you never had to think about.&lt;/p&gt;

&lt;p&gt;This is the opener of a new series on local Kubernetes development. It's a mental-model piece, not a tutorial — the goal is to name the two distinct problems most people blur together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The inner loop is the loop you crank dozens of times an hour&lt;/strong&gt;: edit → build → run → check → fix, before you ever &lt;code&gt;git push&lt;/code&gt;. Keeping it in the seconds is what protects your flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes wedges four extra steps&lt;/strong&gt; into that loop: &lt;code&gt;docker build&lt;/code&gt;, update manifest, &lt;code&gt;docker push&lt;/code&gt;, apply and wait for the Pod. That's the 2–5 min/iteration tax — vs ~1–5 s with file-sync/hot-reload (a 95%+ reduction).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The local cluster can't see images in your local Docker daemon.&lt;/strong&gt; Skip the push/import step and you get a Pod stuck in &lt;code&gt;ImagePullBackOff&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;:latest&lt;/code&gt; trap&lt;/strong&gt;: &lt;code&gt;myapp:latest&lt;/code&gt; defaults to &lt;code&gt;imagePullPolicy: Always&lt;/code&gt;, so the kubelet re-pulls every start. Use specific tags + &lt;code&gt;IfNotPresent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low fidelity is the &lt;em&gt;other&lt;/em&gt; half of the pain&lt;/strong&gt;: OOMKilled, CPU throttling, NetworkPolicy blocks, readiness failures, RBAC denials — a whole class of bugs invisible under bare &lt;code&gt;uvicorn&lt;/code&gt; or &lt;code&gt;docker compose&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reframe shift-left as environment fidelity.&lt;/strong&gt; As Testkube puts it: "testing earlier in a CI container that doesn't match your cluster isn't shift-left — it's just failing faster in the wrong environment."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The running example throughout the series is &lt;code&gt;myapp&lt;/code&gt;: a Python 3.12 + FastAPI HTTP API on port 8080 that depends on PostgreSQL, run on a local k3d cluster.&lt;/p&gt;

&lt;p&gt;Full article: &lt;a href="https://dorokhovich.com/blog/local-k8s-inner-dev-loop?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-inner-dev-loop" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-inner-dev-loop?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-inner-dev-loop&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
