<?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 Stopped Being Scared of Rails Migrations (and Learned to Love Zero-Downtime Deploys)</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:12:30 +0000</pubDate>
      <link>https://dev.to/mnvasil/how-i-stopped-being-scared-of-rails-migrations-and-learned-to-love-zero-downtime-deploys-23k1</link>
      <guid>https://dev.to/mnvasil/how-i-stopped-being-scared-of-rails-migrations-and-learned-to-love-zero-downtime-deploys-23k1</guid>
      <description>&lt;p&gt;I used to have a little ritual before every risky deploy: write the &lt;code&gt;up&lt;/code&gt;, write a matching &lt;code&gt;down&lt;/code&gt;, and tell myself that if anything went wrong I could just roll it back. It was a comfort blanket. And like most comfort blankets, it was hiding the fact that I was cold. This is the story of how I learned that rails zero downtime migrations aren't about being able to undo things - they're about designing so you never need to.&lt;/p&gt;

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

&lt;p&gt;The migration that finally broke the spell looked completely, boringly safe in review. It added a compound index to speed up product search:&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;OptimizeProductSearch&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;up&lt;/span&gt;
    &lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:category_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:created_at&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
              &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s1"&gt;'idx_products_category_price_date'&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;down&lt;/span&gt;
    &lt;span class="n"&gt;remove_index&lt;/span&gt; &lt;span class="ss"&gt;:products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s1"&gt;'idx_products_category_price_date'&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 &lt;code&gt;up&lt;/code&gt; built the index over 15-45 minutes under shared locks - slow queries, a sad p95, but survivable. The trap was the &lt;code&gt;down&lt;/code&gt;. When a deploy went sideways and the pipeline cheerfully auto-rolled-back, the &lt;code&gt;DROP INDEX&lt;/code&gt; grabbed an &lt;strong&gt;exclusive lock&lt;/strong&gt; for a few seconds. On our busiest table, that was a brief but total outage. Sit with that for a second: the rollback - the thing I'd added specifically to keep us safe - &lt;em&gt;was&lt;/em&gt; the incident. I found &lt;a href="https://dorokhovich.com/blog/rails-database-schema-evolution?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=rails-database-schema-evolution" rel="noopener noreferrer"&gt;a writeup that argues exactly this point&lt;/a&gt;, that in high-load systems a rollback-first mindset manufactures the worst failure modes, and reading it was a little embarrassing, because it named a thing I'd been doing to myself for years.&lt;/p&gt;

&lt;p&gt;Once I saw it, I saw it everywhere. Dropping an index or column freezes the fast path with exclusive locks at exactly the moment traffic is highest. Data migrations lose information, so recreating the original is wishful thinking. Rolling code back while the DB is half-migrated leaves a ghost state nobody can reason about mid-incident. And teams that fear schema changes quietly stop shipping the refactors they actually need - which is the slow, invisible cost that hurts most.&lt;/p&gt;

&lt;p&gt;The irreversibility one deserves a concrete example, because people underestimate it. We had a migration that normalized free-form addresses into &lt;code&gt;city&lt;/code&gt;, &lt;code&gt;country&lt;/code&gt;, and &lt;code&gt;postal_code&lt;/code&gt;, then dropped the original &lt;code&gt;address&lt;/code&gt; column. The &lt;code&gt;down&lt;/code&gt; gamely tried to reconstruct the address by string-joining the pieces back together - but the formatting, the ordering, the apartment numbers were simply gone. That migration was irreversible without data loss, full stop. Writing a fantasy &lt;code&gt;down&lt;/code&gt; for it was worse than useless; it handed me false confidence I'd cash in at the worst possible time.&lt;/p&gt;

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

&lt;p&gt;The fix turned out to be a discipline, not a gem. It's what Martin Fowler calls &lt;a href="https://martinfowler.com/bliki/ParallelChange.html" rel="noopener noreferrer"&gt;Parallel Change&lt;/a&gt;, and what database folks call expand and contract. Every change becomes a little sequence of small, individually-deployable steps, each one compatible with the app version before it. You &lt;strong&gt;expand&lt;/strong&gt; - add new structures without blocking, and leave the old ones alone. You &lt;strong&gt;migrate&lt;/strong&gt; - backfill, dual-write, move reads over gradually. Then you &lt;strong&gt;contract&lt;/strong&gt; - remove the legacy stuff, but only after a full compatibility window has passed.&lt;/p&gt;

&lt;p&gt;That compatibility window is the part everyone skips and everyone regrets. The new schema has to keep supporting the &lt;em&gt;previous&lt;/em&gt; app version for at least one deploy cycle. Do that, and rolling code back is a non-event - the old code still runs perfectly against the expanded schema, no ghost state, no cold sweat. The whole trick is just refusing to collapse those three steps back into one clever migration, no matter how much your inner tidiness gremlin wants you to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dual-writes are the bridge
&lt;/h2&gt;

&lt;p&gt;During the window I write to both the old and new structures, so either app version reads consistent data, and I move reads over slowly:&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;User&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:user_statuses&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="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:effective_from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_status&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;has_attribute?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;write_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# legacy field&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="n"&gt;user_statuses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;                  &lt;span class="c1"&gt;# always maintain the new system&lt;/span&gt;
      &lt;span class="ss"&gt;status_type: &lt;/span&gt;&lt;span class="no"&gt;UserStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status_types&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;new_status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;effective_from: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&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;When touching every write path felt too risky, I leaned on a database trigger to auto-sync legacy writes into the new table for the length of the rollout, then dropped the trigger in the contract phase. Either way the rule is the same, and it's the whole point: nobody, ever, reads a half-populated new table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never take a lock you can't afford
&lt;/h2&gt;

&lt;p&gt;The other half of zero-downtime is refusing to grab a lock that'll hurt. The rule the &lt;a href="https://guides.rubyonrails.org/active_record_migrations.html" rel="noopener noreferrer"&gt;Rails migrations guide&lt;/a&gt; and strong_migrations both nudge you toward: add nullable columns with no default (fast), backfill asynchronously in batches, then add the NOT NULL constraint in a separate later migration.&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;AddColumnWithoutDowntime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="n"&gt;disable_ddl_transaction!&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;up&lt;/span&gt;
    &lt;span class="n"&gt;add_column&lt;/span&gt; &lt;span class="ss"&gt;:large_table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:new_field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:string&lt;/span&gt;  &lt;span class="c1"&gt;# fast, no default&lt;/span&gt;
    &lt;span class="n"&gt;queue_background_migration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'FillNewFieldJob'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# backfill in batches&lt;/span&gt;
    &lt;span class="c1"&gt;# change_column_null later, once backfill completes&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 background job walks the table in ordered batches and re-enqueues itself until it runs out of rows, so a table with tens of millions of rows never holds one long transaction or a table-wide lock. On Postgres the same instinct means &lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html" rel="noopener noreferrer"&gt;building indexes concurrently&lt;/a&gt; - &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; skips the write-blocking lock, at the cost of a second table scan. Cheap trade. Take it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rehearsal that ended most of my incidents
&lt;/h2&gt;

&lt;p&gt;Here's the embarrassingly cheap thing that fixed the most pain: I started running migrations against production-sized data in staging before they ever touched production. A change that runs in 40 milliseconds on a seed database can run for 40 minutes against 50 million rows, and there is no way to feel that difference on your laptop. So I'd generate a production-scale dataset, run each pending migration inside a transaction I rolled back for repeatability, and record duration, memory delta, and any blocking queries it kicked off. The output was a short report the team actually read before sign-off. Nine times out of ten the rehearsal caught the problem - a missing concurrent flag, an unbatched backfill - back when it was still boring to fix, which is the only time fixing anything is fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the safety rails automatic
&lt;/h2&gt;

&lt;p&gt;Discipline that depends on everyone remembering is discipline that evaporates at 2 AM. So I pushed the rules into tooling. If you're on Rails, the &lt;a href="https://github.com/ankane/strong_migrations" rel="noopener noreferrer"&gt;&lt;code&gt;strong_migrations&lt;/code&gt; gem&lt;/a&gt; already codifies most of this and is the fastest possible start - it fails the build on unsafe operations and prints the safe rewrite right there in your face. I layered a little duration estimator on top. A CI check refuses the deploy if a recent migration adds a NOT NULL column with no default, creates a blocking index, or is estimated to run long:&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;if&lt;/span&gt; &lt;span class="n"&gt;total_estimated_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;  &lt;span class="c1"&gt;# 5 minutes&lt;/span&gt;
  &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'FORCE_LONG_MIGRATION'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;
    &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s1"&gt;'Set FORCE_LONG_MIGRATION=true to proceed'&lt;/span&gt;
    &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="mi"&gt;1&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;I also instrument the migrations themselves - subscribing to &lt;code&gt;sql.active_record&lt;/code&gt; notifications, logging any migration query over a second and pushing its duration to StatsD, so a slow migration shows up on a dashboard instead of in an incident channel. And before anything risky runs in production, a safety-net step snapshots the affected tables so there's a credible recovery point. Estimators, circuit breakers around big data migrations, a staging rehearsal task - you build the set once and reuse it across every service, and it pays you back forever.&lt;/p&gt;

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

&lt;p&gt;Forward-only evolution didn't just change my migrations, it changed how the deploy button &lt;em&gt;feels&lt;/em&gt;. Instead of fear I have a checklist: design for backward compatibility, expand first and contract later, split the scary change into small ones so data migrates asynchronously while reads drift over, and recover forward with fixes and checkpoints instead of fantasy rollbacks. None of it is clever. It's mostly the willingness to turn one terrifying migration into three boring ones - and I've made my peace with boring.&lt;/p&gt;

&lt;p&gt;The part I didn't expect was cultural. When schema changes stopped being scary, the team stopped avoiding them, and all those long-postponed refactors we'd been quietly routing around finally shipped. That's the real payoff. If your database changes still page your on-call, start with just two things: the compatibility window, and the pre-deploy check. They're the two highest-leverage moves, and they're the ones that let you sleep.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Martin Fowler - Parallel Change (expand and contract): &lt;a href="https://martinfowler.com/bliki/ParallelChange.html" rel="noopener noreferrer"&gt;https://martinfowler.com/bliki/ParallelChange.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;strong_migrations - catch unsafe migrations in development (GitHub): &lt;a href="https://github.com/ankane/strong_migrations" rel="noopener noreferrer"&gt;https://github.com/ankane/strong_migrations&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL - CREATE INDEX, including the CONCURRENTLY option: &lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/sql-createindex.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rails - Active Record Migrations guide: &lt;a href="https://guides.rubyonrails.org/active_record_migrations.html" rel="noopener noreferrer"&gt;https://guides.rubyonrails.org/active_record_migrations.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A thorough end-to-end zero-downtime schema evolution playbook someone published, estimators and staging rehearsal task included: &lt;a href="https://dorokhovich.com/blog/rails-database-schema-evolution?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=rails-database-schema-evolution" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/rails-database-schema-evolution?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=rails-database-schema-evolution&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>The Bug That Made Me Fall Back in Love With rack.response_finished</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:50:43 +0000</pubDate>
      <link>https://dev.to/mnvasil/the-bug-that-made-me-fall-back-in-love-with-rackresponsefinished-267d</link>
      <guid>https://dev.to/mnvasil/the-bug-that-made-me-fall-back-in-love-with-rackresponsefinished-267d</guid>
      <description>&lt;p&gt;There is a particular kind of production bug that doesn't feel like a bug. It feels like weather. Every few days something leaks, a worker gets cranky, p99 creeps up during a traffic spike, and you shrug and restart it because it clears up on its own. For the better part of a year that was my relationship with &lt;code&gt;rack.response_finished&lt;/code&gt; - except I didn't know that was the name of my problem yet. I just knew our Rails app was quietly bleeding resources and I kept blaming the wrong things.&lt;/p&gt;

&lt;p&gt;This is the story of how it finally clicked, why it bugged me for so long, and how I migrated a high-traffic app to &lt;code&gt;rack.response_finished&lt;/code&gt; without spending a single night watching a dashboard with my stomach in knots.&lt;/p&gt;

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

&lt;p&gt;Our symptoms were the vague kind that make you feel a little crazy. Occasional resource leaks under load. Tail latency that crept up when traffic spiked and settled back down when it didn't. Metrics that never quite matched what I believed was happening. We streamed large files and Server-Sent Events, so the Rack triplet &lt;code&gt;[status, headers, body]&lt;/code&gt; was returning long before the last byte ever reached the client.&lt;/p&gt;

&lt;p&gt;Here's the piece I genuinely did not appreciate for months: our middleware was freeing resources - closing DB connections, clearing caches, wiping thread-locals - well before the client had the full response. I found a lovely deep-dive on exactly &lt;a href="https://dorokhovich.com/blog/rack-response-finished?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=rack-response-finished" rel="noopener noreferrer"&gt;what happens between returning a Rack triplet and delivering that last byte&lt;/a&gt;, and reading it was the moment the fog lifted. All those vague symptoms suddenly had one concrete cause. We'd been treating a lifecycle bug as an infrastructure flake - restarting workers, bumping pool sizes, side-eyeing the load balancer. It was none of that. It was an assumption I'd baked into how we wrapped response bodies, and I'd never once questioned it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I'd been doing without thinking
&lt;/h2&gt;

&lt;p&gt;For years, if you wanted a "run this when the request is done" hook, you wrapped the body in &lt;a href="https://www.rubydoc.info/gems/rack/Rack/BodyProxy" rel="noopener noreferrer"&gt;&lt;code&gt;Rack::BodyProxy&lt;/code&gt;&lt;/a&gt;. It's a tidy little object that calls your block when the wrapped body closes. Elegant on paper. The trouble is every middleware that wanted a callback added its own wrapper, so a single request ended up dragging a Russian-doll stack of proxies around:&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="n"&gt;original_body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Hello World"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BodyProxy&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;original_body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt; &lt;span class="s2"&gt;"Request finished"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BodyProxy&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;body&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record_latency&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BodyProxy&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;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cleanup_thread_locals&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BodyProxy&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;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;close_db_connections&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Shopify Rails infrastructure folks wrote up this exact pain in &lt;a href="https://railsatscale.com/2025-08-26-friendship-ended-with-rack-bodyproxy/" rel="noopener noreferrer"&gt;"Friendship Ended with Rack::BodyProxy"&lt;/a&gt;, and honestly it was validating to read, because it named three things that had been nagging at me.&lt;/p&gt;

&lt;p&gt;First, allocation pressure. Every proxy is one more object. At tens of thousands of requests a second, even tiny per-request allocations add up - and because these callbacks capture closures, the objects hang around long enough to get promoted into older GC generations, which is exactly where you don't want churn.&lt;/p&gt;

&lt;p&gt;Second, timing you can't trust. &lt;code&gt;#close&lt;/code&gt; gets called by the server, sure, but the spec never promised it happens &lt;em&gt;after&lt;/em&gt; the client has everything. Depending on the server and buffering, my callbacks could fire before, during, or after transmission. I was cleaning up at a moment I couldn't actually pin down.&lt;/p&gt;

&lt;p&gt;Third - and this is the one that was actually paging me at 3am - exceptions skipped cleanup entirely. If the body raised while iterating, the proxy's callback might just never run:&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;ProblematicBody&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;each&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="s2"&gt;"Part 1"&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"Something went wrong"&lt;/span&gt;  &lt;span class="c1"&gt;# BodyProxy#close might not be called&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="s2"&gt;"Part 2"&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;That right there was the leak. A stream raises halfway through a big download, the cleanup silently gets skipped, and the resource just... stays open. Multiply by traffic and you get weather.&lt;/p&gt;

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

&lt;p&gt;The replacement is so much calmer, and I mean that emotionally as much as technically. Instead of N proxy objects, there's one standard key in &lt;code&gt;env&lt;/code&gt; holding an array of callbacks:&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;def&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rack.response_finished"&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="n"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;lambda&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="c1"&gt;# Runs AFTER complete response delivery&lt;/span&gt;
    &lt;span class="n"&gt;cleanup_resources&lt;/span&gt;
    &lt;span class="n"&gt;log_metrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="vi"&gt;@app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What sold me wasn't the elegance, it was the promise. Unlike &lt;code&gt;BodyProxy#close&lt;/code&gt;, these callbacks are guaranteed to run in three cases: a clean finish after all data is sent, an application exception even if the body never started iterating, and a server exception during network trouble. Each callback gets &lt;code&gt;(env, status, headers, error)&lt;/code&gt;, and per &lt;a href="https://github.com/rack/rack/pull/1952" rel="noopener noreferrer"&gt;Rack's Lint spec&lt;/a&gt; they fire in reverse registration order, so you can branch on what actually happened. It shipped as part of &lt;a href="https://github.com/rack/rack/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;Rack 3.x&lt;/a&gt;, and Puma added real server-side support (&lt;a href="https://github.com/puma/puma/pull/3681" rel="noopener noreferrer"&gt;puma#3681&lt;/a&gt;) so it isn't a quiet no-op in production.&lt;/p&gt;

&lt;p&gt;The one guarantee I kept coming back to: the callbacks run even when the body raises mid-stream. That single sentence killed my entire class of leaks. I remember reading it and feeling almost annoyed at how simple the fix was.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I migrated without holding my breath
&lt;/h2&gt;

&lt;p&gt;I couldn't flip everything at once - our services spanned Rack versions, and I'm allergic to big-bang changes on infrastructure I can't fully see. So the critical middleware learned to speak both dialects and pick whichever the server offered:&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;SafeMigrationMiddleware&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&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="vi"&gt;@app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&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;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rack.response_finished"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="n"&gt;register_new_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wrap_with_proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# fallback&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;register_new_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rack.response_finished"&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="n"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:cleanup_resources&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;def&lt;/span&gt; &lt;span class="nf"&gt;wrap_with_proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;Rack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BodyProxy&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;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cleanup_resources&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;def&lt;/span&gt; &lt;span class="nf"&gt;cleanup_resources&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="c1"&gt;# accepts any number of args&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt; &lt;span class="s2"&gt;"Request completed"&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 &lt;code&gt;cleanup_resources(*)&lt;/code&gt; splat is on purpose - the new API hands you four arguments, the proxy hands you none, and a splat lets one method serve both paths without a branch. Small thing, but it made the diff read cleanly, which matters to me more than I'll admit.&lt;/p&gt;

&lt;p&gt;Then I refused to guess. I put a StatsD counter on each path so I could watch, in real numbers, how much traffic had moved to the new mechanism:&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;if&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rack.response_finished"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="no"&gt;StatsD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'middleware.response_finished.new_api'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;register_new_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="no"&gt;StatsD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'middleware.response_finished.fallback'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wrap_with_proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The mistake I almost shipped
&lt;/h2&gt;

&lt;p&gt;Callbacks run in the same thread as the request. That makes thread-local cleanup delightful, and it makes slow work a trap. My first draft casually fired off a notification email inside the callback, which is a wonderful way to block a worker for seconds at a time. Fast cleanup belongs in the callback; anything with I/O belongs in a background job:&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="c1"&gt;# Good: fast cleanup&lt;/span&gt;
&lt;span class="n"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;lambda&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="no"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:request_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;
  &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear_active_connections!&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rails itself gets happier here too: &lt;code&gt;ActionDispatch::Executor&lt;/code&gt; can now reliably clear thread-locals right after the response completes, and gems like rack-timeout, newrelic_rpm, skylight, and sentry-ruby line up their timing and errors far more accurately.&lt;/p&gt;

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

&lt;p&gt;I want to be honest about the size of the win, because it's easy to oversell a refactor you're proud of. Average latency barely moved - most requests were never the problem. What changed was the &lt;em&gt;shape&lt;/em&gt; of things. Objects allocated per request dropped on our streaming endpoints, major GC ran a little less often, and the p99 tail got quieter during spikes because fewer long-lived closures were being promoted into old-gen. And the leak alerts that used to page me during big downloads simply stopped, because the cleanup now runs even when the body raises.&lt;/p&gt;

&lt;p&gt;The order of operations mattered as much as the code. I audited every middleware that touched &lt;code&gt;BodyProxy&lt;/code&gt;, bumped Rack to 3.x in development first, shipped the dual-API middleware, and let the StatsD counters tell me when the new path dominated. I only pulled the fallback after a service had run for weeks at effectively 100% new-API with zero callback errors. I left it in longer than felt necessary, on purpose - rushing that last step is exactly how you turn a calm migration into a scary one. Profiling before and after with memory_profiler and ruby-prof gave me actual numbers instead of a vibe, which is the difference between "trust me" and a graph.&lt;/p&gt;

&lt;p&gt;If I could hand one note back to the version of me who kept restarting workers: the leak was never infrastructure. It was an abstraction I'd stopped questioning because it looked so tidy. &lt;code&gt;BodyProxy&lt;/code&gt; was elegant right up until it wasn't, and the replacement is simpler &lt;em&gt;and&lt;/em&gt; more honest about what it promises. That combination - less clever, more trustworthy - is the trade I'll take every single time now. If you run a busy Rack app, start the dual-API pattern today; you get to learn the new mechanism at zero risk, and future-you gets to sleep.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Rack - Add &lt;code&gt;rack.response_finished&lt;/code&gt; to &lt;code&gt;Rack::Lint&lt;/code&gt; (PR #1952, the spec and reverse-order contract): &lt;a href="https://github.com/rack/rack/pull/1952" rel="noopener noreferrer"&gt;https://github.com/rack/rack/pull/1952&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rack CHANGELOG (where &lt;code&gt;rack.response_finished&lt;/code&gt; and Rack 3.x changes land): &lt;a href="https://github.com/rack/rack/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;https://github.com/rack/rack/blob/main/CHANGELOG.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Puma - Add support for &lt;code&gt;rack.response_finished&lt;/code&gt; (server-side implementation, PR #3681): &lt;a href="https://github.com/puma/puma/pull/3681" rel="noopener noreferrer"&gt;https://github.com/puma/puma/pull/3681&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rails at Scale - "Friendship Ended with Rack::BodyProxy" (Shopify's writeup of the same migration): &lt;a href="https://railsatscale.com/2025-08-26-friendship-ended-with-rack-bodyproxy/" rel="noopener noreferrer"&gt;https://railsatscale.com/2025-08-26-friendship-ended-with-rack-bodyproxy/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rack::BodyProxy API documentation (RubyDoc): &lt;a href="https://www.rubydoc.info/gems/rack/Rack/BodyProxy" rel="noopener noreferrer"&gt;https://www.rubydoc.info/gems/rack/Rack/BodyProxy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A production migration writeup someone put together, with the full dual-API middleware and monitoring metrics laid out: &lt;a href="https://dorokhovich.com/blog/rack-response-finished?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=rack-response-finished" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/rack-response-finished?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=rack-response-finished&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>debugging</category>
      <category>performance</category>
      <category>rails</category>
      <category>ruby</category>
    </item>
    <item>
      <title>How I Rescued My Broken Local Kubernetes Development Environment in One Afternoon (Docker + k3d + Tilt)</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Sat, 08 Aug 2026 17:16:58 +0000</pubDate>
      <link>https://dev.to/mnvasil/how-i-rescued-my-broken-local-kubernetes-development-environment-in-one-afternoon-docker-k3d--h50</link>
      <guid>https://dev.to/mnvasil/how-i-rescued-my-broken-local-kubernetes-development-environment-in-one-afternoon-docker-k3d--h50</guid>
      <description>&lt;p&gt;There's a specific flavor of shame in a local Kubernetes development environment that only you can't get working. Everyone else on the team runs &lt;code&gt;kubectl get pods&lt;/code&gt; and gets an answer. You run it and get &lt;code&gt;connection refused&lt;/code&gt;, again, and you quietly start to wonder if the problem is you. It wasn't me, it turned out - it was that I'd never actually &lt;em&gt;built&lt;/em&gt; the thing, I'd just accreted it. This is the story of how I tore my broken setup down and rebuilt it, deliberately, in a single afternoon.&lt;/p&gt;

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

&lt;p&gt;I'd inherited a FastAPI service - Python 3.12, listening on 8080, backed by PostgreSQL - that the team ran on "local Kubernetes." That phrase meant something different on every laptop, and mine was easily the worst offender. kubectl installed three different ways. A Docker Desktop that half-started on a good day. A &lt;code&gt;helm&lt;/code&gt; binary that wasn't on my &lt;code&gt;PATH&lt;/code&gt;. And, crucially, no cluster at all. Every single &lt;code&gt;kubectl&lt;/code&gt; command greeted me with the same &lt;code&gt;connection refused&lt;/code&gt;, and I'd learned to just... route around it, which is the worst possible response and exactly the one I kept choosing.&lt;/p&gt;

&lt;p&gt;I burned two full mornings guessing. Then I got tired of guessing and followed a single opinionated write-up that treats the workstation itself as the deliverable - the idea being that you're not done until &lt;code&gt;kubectl get pods -A&lt;/code&gt; responds without errors against a real local cluster. That reframing did something to my brain. If you want the full chapter with every OS variant, &lt;a href="https://dorokhovich.com/blog/local-k8s-workstation-setup?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-workstation-setup" rel="noopener noreferrer"&gt;someone wrote up the exact workstation setup here&lt;/a&gt;, and it's the map I wish I'd had two mornings earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that finally clicked: install bottom-up
&lt;/h2&gt;

&lt;p&gt;The mindset shift that fixed everything was embarrassingly simple - install in dependency order. Docker first, because nothing runs without it, then kubectl, k3d, helm, and Tilt last. On my Mac that collapsed to almost one command:&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;# Docker Desktop from docker.com (daemon + GUI), then:&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;kubectl helm k3d tilt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;k3d is the quiet hero of this whole story. It's just &lt;a href="https://k3d.io/" rel="noopener noreferrer"&gt;k3s packaged into Docker containers&lt;/a&gt; - k3s being a lightweight, CNCF-certified Kubernetes distribution - so a local cluster is featherweight next to "real" Kubernetes. If you're agonizing over &lt;strong&gt;k3d vs kind vs minikube&lt;/strong&gt;, here's what decided it for me: k3d spins up a cluster in under five seconds, ships a built-in image registry that Tilt adores, and barely touches RAM, whereas minikube boots a whole VM and &lt;a href="https://kind.sigs.k8s.io/" rel="noopener noreferrer"&gt;kind&lt;/a&gt; - Kubernetes-IN-Docker, originally built to test Kubernetes itself - sits somewhere in the middle. For a tight inner dev loop I rebuild dozens of times a day, and those saved seconds compound into genuinely real time. The one hard requirement is Docker 20.10.5 or newer; k3d's &lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;own repository&lt;/a&gt; lists a working Docker install as the sole prerequisite. Everything else is just host headroom - 8 GB of RAM is a comfortable floor, 16 GB means you never think about it, and you'll want 10 to 20 GB of free disk for images and layers.&lt;/p&gt;

&lt;p&gt;On Linux the shape is identical, except you install Docker Engine with no GUI and you must add yourself to the docker group or every command demands sudo:&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;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;span class="c"&gt;# then log out and back in so the group takes effect&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caution I learned the hard way: those &lt;code&gt;curl ... | bash&lt;/code&gt; installers for k3d, helm, and Tilt are convenient, but you're piping someone else's code from the internet straight into a shell. On a work machine I now download the script, skim it, then run it - or reach for the apt/dnf packages the official docs offer for kubectl and helm, which are cleaner to maintain long-term anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming what each tool actually does
&lt;/h2&gt;

&lt;p&gt;Here's the confession at the heart of this: before that afternoon, I genuinely could not have told you which tool did what, and that fuzziness is exactly why my setup was so fragile. The mental model that finally stuck goes like this. Docker builds images and runs the containers everything else sits inside. k3d spins up the cluster itself - k3s in Docker - and tears it down just as fast. kubectl and helm manage resources and packaged charts inside that cluster. Tilt runs the fast inner dev loop, watching your files and rebuilding and redeploying on every save, and its &lt;a href="https://docs.tilt.dev/tutorial/5-live-update.html" rel="noopener noreferrer"&gt;Live Update&lt;/a&gt; can even sync changed files straight into a running container, collapsing edit-build-push-deploy into seconds. And k9s is the optional terminal UI that replaces a wall of kubectl commands.&lt;/p&gt;

&lt;p&gt;Once I could name the job of each binary, the errors stopped being mysterious and started being addressable. A &lt;code&gt;connection refused&lt;/code&gt; is Docker. A stuck rebuild is Tilt. A missing chart is helm. The abstraction layers finally lined up in my head, and honestly that was more valuable than any single command I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smoke test that ends the guessing
&lt;/h2&gt;

&lt;p&gt;This is the part I wish I'd run on day one instead of day three. Rather than debugging four tools in isolation, you prove the whole chain works together at once: create a throwaway cluster, switch to it, look at the system pods, delete it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;k3d cluster create dev
k3d kubeconfig merge dev &lt;span class="nt"&gt;--kubeconfig-switch-context&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt;
k3d cluster delete dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When that &lt;code&gt;kubectl get pods -A&lt;/code&gt; printed coredns, traefik, and metrics-server all sitting in &lt;code&gt;Running&lt;/code&gt;, I actually said it out loud to my empty room: the workstation was ready. That single sequence is the entire difference between "I think it's installed" and "I proved it works," and I've never set up a machine without it since. Naming the cluster &lt;code&gt;dev&lt;/code&gt; and reusing it later is deliberate too - k3d makes clusters so cheap to create and destroy that a named, reusable &lt;code&gt;dev&lt;/code&gt; cluster becomes the stable target for the rest of your toolchain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas that used to eat my mornings
&lt;/h2&gt;

&lt;p&gt;Almost every failure at this stage is one of a small handful of classics, and just running the list saved me hours. The most common by far is simply that the Docker daemon isn't running - k3d and Tilt both die with &lt;code&gt;connection refused&lt;/code&gt;, so start Docker Desktop or &lt;code&gt;sudo systemctl start docker&lt;/code&gt; and wait for it to fully come up. After a reboot you may hit &lt;code&gt;port already in use&lt;/code&gt;, in which case just recreate the cluster.&lt;/p&gt;

&lt;p&gt;The trickiest one, and the one that bit me hardest, is &lt;code&gt;too many open files&lt;/code&gt;. Pods and Tilt both watch files, and they collide with system inotify limits; the symptom shows up as &lt;code&gt;too many open files&lt;/code&gt; in pod logs or the kubelet. On a Linux or Docker host the fix is:&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;sysctl fs.inotify.max_user_watches&lt;span class="o"&gt;=&lt;/span&gt;1048576
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl fs.inotify.max_user_instances&lt;span class="o"&gt;=&lt;/span&gt;8192
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it survive reboots by putting those in a file under &lt;code&gt;/etc/sysctl.d/&lt;/code&gt;, and as a fallback for stubborn machines you can create a k3d cluster with only a server node and no agents, which cuts the total open files.&lt;/p&gt;

&lt;p&gt;Windows has its own trap: do all your dev work inside WSL2, including the repo itself. A teammate kept the code on &lt;code&gt;C:\&lt;/code&gt; and ran the tools from WSL2, and the result was a crawling filesystem and Tilt file sync that silently refused to trigger &lt;code&gt;uvicorn --reload&lt;/code&gt;. Move the repo into the WSL2 home directory and the whole problem evaporates. And two quieter ones round it out - if Docker Desktop refuses to start with a virtualization complaint, enable hardware virtualization (VT-x / AMD-V, plus SLAT for WSL2) in BIOS/UEFI, and if you try to squeeze into 4 GB, expect OOM once PostgreSQL and a few pods pile in. Give Docker 8 GB or more under Settings then Resources and the random pod deaths stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The little upgrade: k9s made me delete half my muscle memory
&lt;/h2&gt;

&lt;p&gt;Instead of hammering &lt;code&gt;kubectl get pods&lt;/code&gt;, &lt;code&gt;kubectl describe&lt;/code&gt;, and &lt;code&gt;kubectl logs&lt;/code&gt; all day, I now open &lt;a href="https://github.com/derailed/k9s" rel="noopener noreferrer"&gt;k9s&lt;/a&gt; - a terminal UI that continually watches the cluster - and navigate with arrow keys. Logs, restarts, deletes, all from one screen.&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;derailed/k9s/k9s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For anyone still finding their feet, it's a genuinely lovely visual replacement for dozens of repetitive commands, and I felt a small pang deleting keystrokes I'd spent years memorizing.&lt;/p&gt;

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

&lt;p&gt;The change is night and day, and it's less about any single number than about the shift from flailing to knowing. What used to be two mornings of guessing is now a checklist-driven afternoon. &lt;code&gt;kubectl get pods -A&lt;/code&gt; returns system pods in &lt;code&gt;Running&lt;/code&gt; instead of &lt;code&gt;connection refused&lt;/code&gt;. Tilt hot reload, which used to be silently broken behind that WSL2 path issue, now fires on every save. Debugging a pod is arrow keys in k9s instead of three kubectl invocations each time. And the biggest one, the one that's hard to put in a table: I'm actually confident it works now, because I watched the smoke test pass with my own eyes.&lt;/p&gt;

&lt;p&gt;With the toolchain proven, the natural next step is spinning up a real dev cluster for the service - built-in registry, port forwarding, the works - rather than a throwaway. But if you're setting this up for the first time, please don't improvise the way I did for two mornings. Install bottom-up, run the smoke test before you touch a line of application code, and keep the gotcha list within reach.&lt;/p&gt;

&lt;p&gt;The thing that genuinely stuck with me is this: a local Kubernetes environment is not "done" when the binaries are installed. It's done when you've &lt;em&gt;watched&lt;/em&gt; the smoke test pass. I'd spent years treating my dev environment as something that accreted rather than something I built, and the fix was simply to build it on purpose, prove it, and only then start stacking real work on top. Prove it first. Everything after that is easier.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;k3d - k3s in Docker, official documentation: &lt;a href="https://k3d.io/" rel="noopener noreferrer"&gt;https://k3d.io/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;k3d install requirements and cluster options (GitHub): &lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;https://github.com/k3d-io/k3d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;kind - Kubernetes IN Docker, official documentation: &lt;a href="https://kind.sigs.k8s.io/" rel="noopener noreferrer"&gt;https://kind.sigs.k8s.io/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tilt Live Update tutorial (official docs): &lt;a href="https://docs.tilt.dev/tutorial/5-live-update.html" rel="noopener noreferrer"&gt;https://docs.tilt.dev/tutorial/5-live-update.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;k9s - Kubernetes terminal UI (GitHub): &lt;a href="https://github.com/derailed/k9s" rel="noopener noreferrer"&gt;https://github.com/derailed/k9s&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/local-k8s-workstation-setup?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-workstation-setup" rel="noopener noreferrer"&gt;A full field-tested workstation write-up someone put together&lt;/a&gt;, with every OS variant and the complete gotcha list&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>infrastructure</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>A Local Kubernetes Tools Comparison That Finally Unfroze Me: Stop Comparing Them, Sort Them Onto Shelves</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:53:58 +0000</pubDate>
      <link>https://dev.to/mnvasil/a-local-kubernetes-tools-comparison-that-finally-unfroze-me-stop-comparing-them-sort-them-onto-10j0</link>
      <guid>https://dev.to/mnvasil/a-local-kubernetes-tools-comparison-that-finally-unfroze-me-stop-comparing-them-sort-them-onto-10j0</guid>
      <description>&lt;p&gt;Here is the confession I don't love making: I once lost an entire week to a decision that should have taken an afternoon. My team wanted local Kubernetes, I volunteered to pick our stack, I opened a "getting started" guide, and got hit with an incantation - k3d, kind, minikube, Helm, Kustomize, Tilt, Skaffold, DevSpace, Telepresence, mirrord, k9s. I froze. Every local Kubernetes tools comparison I read stacked a dozen names side by side as if they were rival answers to one question, and I couldn't pick anything. What eventually unfroze me wasn't a better comparison - it was realizing they don't belong on the same shelf.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bugged me for a whole week
&lt;/h2&gt;

&lt;p&gt;I'm a fairly decisive person, which made the paralysis extra humiliating. I figured picking a stack would be a lazy afternoon of reading. Instead I kept hitting the same wall: do I need k3d &lt;em&gt;or&lt;/em&gt; Helm? Is Tilt an alternative to minikube? Where on earth does Telepresence fit? Every post read like one giant menu where everything competed with everything, and I genuinely could not tell whether I was choosing one tool or ten.&lt;/p&gt;

&lt;p&gt;The reframing that saved me came from an &lt;a href="https://dorokhovich.com/blog/local-k8s-tooling-overview?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-tooling-overview" rel="noopener noreferrer"&gt;overview of the local Kubernetes tooling landscape someone wrote up&lt;/a&gt;: each tool handles its own little piece, and once you sort them onto shelves, the whole picture goes quiet. Let me hand you the shelves, because they cost me a week and they might save you one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first fork nobody told me about: local cluster vs remote connection
&lt;/h2&gt;

&lt;p&gt;Before you compare any specific tool, there's a bigger split I didn't even know existed. There are two fundamentally different ways to get a near-production loop. The first is to run all of Kubernetes on your own machine - spin up a small but real cluster locally with k3d, kind, minikube, Docker Desktop, or Rancher Desktop, and deploy with the same manifests you'll use in prod. Fully local, no network dependency. That's the path we took. The second is to keep your code local but plug it into a &lt;em&gt;remote&lt;/em&gt; cluster, so your local process behaves as if it were a Pod living on staging. That's the world of Telepresence (which builds a VPN-style tunnel, needs root, and modifies the cluster), mirrord (which injects into your process via &lt;code&gt;LD_PRELOAD&lt;/code&gt; or &lt;code&gt;DYLD_INSERT_LIBRARIES&lt;/code&gt;, needs no root, and mirrors by default so it's safe for shared staging), and Gefyra (VPN-based, no root, Docker-only).&lt;/p&gt;

&lt;p&gt;The remote approach is genuinely great, but it needs a live remote cluster to maintain and network access - an extra layer of magic I didn't want to impose on a team still finding its feet. So: everything local. That one decision alone eliminated three tools from my "must choose now" list, and I felt the paralysis loosen its grip a little.&lt;/p&gt;

&lt;h2&gt;
  
  
  The foundation shelf: Docker and kubectl
&lt;/h2&gt;

&lt;p&gt;These sit under everything else. Docker is the container engine, and here's the fact that surprised half my team: local clusters run their Kubernetes &lt;em&gt;nodes&lt;/em&gt; as ordinary Docker containers. A node is a container with Kubernetes inside it, and inside &lt;em&gt;that&lt;/em&gt; run your Pods. No Docker, no cluster. kubectl, meanwhile, is the official CLI and your main channel to the cluster - apply manifests, read logs, forward ports, shell in. Every accelerator like Tilt or Skaffold and every visual tool like k9s runs &lt;em&gt;on top of&lt;/em&gt; kubectl, calling the same Kubernetes API underneath, so it's worth actually knowing even if you end up living inside Tilt all day. A small bonus that trips people up: Kustomize is built right into kubectl via &lt;code&gt;kubectl apply -k&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cluster shelf: k3d, kind, minikube, Docker/Rancher Desktop
&lt;/h2&gt;

&lt;p&gt;This shelf holds the "box" your app goes into, and all of these give you a real Kubernetes API - they just differ in startup speed, resource appetite, and features. &lt;a href="https://k3d.io/" rel="noopener noreferrer"&gt;k3d&lt;/a&gt; is a wrapper that runs k3s, a minimal certified Kubernetes, inside Docker; it has the fastest startup, the lowest memory footprint, and a handy built-in registry, which makes it my pick for everyday dev (with the honest caveat that it's a community project, not an official Rancher/SUSE product). &lt;a href="https://kind.sigs.k8s.io/" rel="noopener noreferrer"&gt;kind&lt;/a&gt;, Kubernetes IN Docker, runs upstream Kubernetes via kubeadm - CNCF-certified, multi-node and HA capable, and built to test Kubernetes itself and CI conformance. minikube runs Kubernetes in a VM or via a docker driver with a big addon ecosystem and remains the classic learning tool. Docker Desktop offers single-node Kubernetes behind a checkbox, convenient if you already run it but limited in configuration. And Rancher Desktop is a GUI app on k3s for native local dev and testing. The 2025-2026 consensus lands on kind or k3d for the best balance of speed and capability, and k3d in particular starts fast and stays light, which is exactly why it's so pleasant to recreate constantly while you're still learning and breaking things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manifest shelf: Helm and Kustomize
&lt;/h2&gt;

&lt;p&gt;Production manifests usually live as Helm charts or Kustomize overlays, and this is the whole reason a local cluster beats docker-compose - it accepts these &lt;em&gt;same&lt;/em&gt; formats. &lt;a href="https://helm.sh/docs/" rel="noopener noreferrer"&gt;Helm&lt;/a&gt; is the Kubernetes package manager: your app becomes a &lt;em&gt;chart&lt;/em&gt; with a &lt;code&gt;Chart.yaml&lt;/code&gt;, a &lt;code&gt;values.yaml&lt;/code&gt;, and a &lt;code&gt;templates/&lt;/code&gt; directory of Go templates, and in return you get versioning, releases, rollbacks, dependencies, and repos. It's been a &lt;a href="https://www.cncf.io/projects/helm/" rel="noopener noreferrer"&gt;CNCF graduated project since 2020&lt;/a&gt;. Kustomize takes the opposite bet - no templates at all; you write base manifests and apply &lt;em&gt;overlays&lt;/em&gt;, which are patches, per environment. It's &lt;a href="https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/" rel="noopener noreferrer"&gt;built into kubectl&lt;/a&gt; since 1.14, though on its own it has no packaging, versioning, or rollback, so people bolt on Argo CD or Flux for that. In practice the two complement each other: Helm to package and distribute, Kustomize to tidily patch per environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accelerator shelf: Tilt, Skaffold, DevSpace
&lt;/h2&gt;

&lt;p&gt;The bare loop - edit, build, push, deploy, look - is excruciatingly slow in Kubernetes, and this shelf exists to automate the building, updating, and watching. Tilt (Apache-2.0) automates watch-build-update, and its signature move is Live Update, syncing changed files straight into the running container without a full rebuild; you configure it in a &lt;code&gt;Tiltfile&lt;/code&gt; written in Starlark, a Python-like language, and it ships a genuinely clear web UI that's a big plus for beginners. Skaffold, from Google, is a build/push/deploy pipeline with file sync, a dedicated &lt;code&gt;skaffold debug&lt;/code&gt; command, and profiles, deploying via kubectl, Helm, or Kustomize. DevSpace is a CLI that leans into bidirectional sync, hot reload, and in-cluster dev containers. Two adjacent names worth not confusing with these: Okteto is about cloud dev environments, which is closer to the remote approach, and Garden is graph-based multi-service automation, which is overkill for a single service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The convenience shelf: k9s and friends
&lt;/h2&gt;

&lt;p&gt;Once you have more than one Pod, typing &lt;code&gt;kubectl get&lt;/code&gt;, &lt;code&gt;logs&lt;/code&gt;, and &lt;code&gt;describe&lt;/code&gt; all day gets old fast. &lt;a href="https://k9scli.io/" rel="noopener noreferrer"&gt;k9s&lt;/a&gt; is a terminal UI - a "visual kubectl" - with vim-style navigation, live Pod, node, and deployment views, and hotkeys (&lt;code&gt;l&lt;/code&gt; for logs, &lt;code&gt;s&lt;/code&gt; for shell, &lt;code&gt;d&lt;/code&gt; for describe). My favorite touch: it color-codes contexts, so you can paint prod an alarming red and never wreck it by muscle memory again. Also worth installing when the day comes are kubectx and kubens for fast context and namespace switching, stern for multi-pod log streaming, and krew, the kubectl plugin manager.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we landed on k3d + Tilt
&lt;/h2&gt;

&lt;p&gt;Once the tools were on shelves, the choice basically made itself, because local Kubernetes dev really only has two core pains: you need a realistic cluster that accepts real production manifests, and you need a fast edit-to-result loop. k3d covers the first - a real Kubernetes API via k3s, fast startup, modest memory, so we get genuine parity with our Helm and Kustomize manifests instead of a docker-compose surrogate, and without turning laptops into space heaters. It's cheap to recreate, which matters enormously while a team is learning. Tilt covers the second - Live Update compresses the loop from minutes to seconds, codifies the whole setup in a &lt;code&gt;Tiltfile&lt;/code&gt;, deploys through real manifests, and shows a web UI where builds, logs, and state all live in one place. Together they're fully local, open source, and free.&lt;/p&gt;

&lt;p&gt;The alternatives are all worthy, and I want to be fair to them: Skaffold and DevSpace solve the same loop and are worth trying if their style fits your team, Telepresence and mirrord are the remote approach if you'd rather lean on a shared cluster, and Okteto and Garden solve adjacent problems entirely. For a single service headed toward its first deploy, k3d + Tilt was simply the shortest, clearest path.&lt;/p&gt;

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

&lt;p&gt;The before-and-after is almost embarrassing in how much of it was in my head. Before the map, my mental model was "ten rival tools, one agonizing choice," the decision took a paralyzed week, and the stack stayed stubbornly undecided the whole time. After the map, the tools sat on distinct shelves, the decision collapsed into an afternoon, and I had a stack - k3d + Tilt - that I could actually explain the reasons for.&lt;/p&gt;

&lt;p&gt;What stays with me is &lt;em&gt;why&lt;/em&gt; I was stuck, because it wasn't complexity. I was overwhelmed because I kept comparing a cluster to a package manager to a file-syncer as though they answered the same question, and they simply don't. The moment I stopped ranking them and started sorting them - foundation, cluster, manifest, accelerator, remote, convenience - the choice made itself. If you're standing at the same frozen starting line, don't reach for one more comparison table. Reach for shelves. That reframing is the thing I'd hand back to the paralyzed version of me who lost a week he didn't need to.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://k3d.io/" rel="noopener noreferrer"&gt;k3d — k3s in Docker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kind.sigs.k8s.io/" rel="noopener noreferrer"&gt;kind — Kubernetes IN Docker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://helm.sh/docs/" rel="noopener noreferrer"&gt;Helm docs — the Kubernetes package manager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.cncf.io/projects/helm/" rel="noopener noreferrer"&gt;CNCF — Helm (graduated project)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/" rel="noopener noreferrer"&gt;Kubernetes docs — Managing objects with Kustomize (built into kubectl)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://k9scli.io/" rel="noopener noreferrer"&gt;k9s — a terminal UI for Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-tooling-overview?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-tooling-overview" rel="noopener noreferrer"&gt;A full local-k8s tooling overview someone put together&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>How Tilt's Live Update Fixed My Kubernetes Dev Loop and Gave Me an Hour a Day Back</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Thu, 06 Aug 2026 09:29:19 +0000</pubDate>
      <link>https://dev.to/mnvasil/how-tilts-live-update-fixed-my-kubernetes-dev-loop-and-gave-me-an-hour-a-day-back-2gpj</link>
      <guid>https://dev.to/mnvasil/how-tilts-live-update-fixed-my-kubernetes-dev-loop-and-gave-me-an-hour-a-day-back-2gpj</guid>
      <description>&lt;p&gt;Change one line of Python. &lt;code&gt;docker build&lt;/code&gt;. &lt;code&gt;docker push&lt;/code&gt;. &lt;code&gt;kubectl rollout restart&lt;/code&gt;. Wait for the pod. Check the logs. Repeat, forty times a day. I actually did the arithmetic on what that ritual was costing me one afternoon, and the number was ugly enough that I nearly gave up developing on Kubernetes entirely. Instead I found Tilt's Live Update, rebuilt my Kubernetes dev loop around it, and got roughly an hour a day back. This is how that went.&lt;/p&gt;

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

&lt;p&gt;My team had just moved local development onto a k3d cluster for parity with prod. Good call in principle - and in practice my day looked like this for every single code change to our FastAPI service &lt;code&gt;myapp&lt;/code&gt;:&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; k3d-registry.localhost:5000/myapp &lt;span class="nb"&gt;.&lt;/span&gt;
docker push k3d-registry.localhost:5000/myapp
kubectl rollout restart deploy/myapp &lt;span class="nt"&gt;-n&lt;/span&gt; myapp
kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/myapp &lt;span class="nt"&gt;-n&lt;/span&gt; myapp   &lt;span class="c"&gt;# wait, watch, hope&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A minute or two, minimum, per edit. Ten edits and half an hour of my life had dissolved into progress bars. But the raw time wasn't even the worst of it - the &lt;em&gt;context switching&lt;/em&gt; was. By the time the pod finally came up, I'd forgotten what I was checking in the first place. Kubernetes had taken my tight, genuinely joyful edit-run loop and turned it into molasses, and I could feel my patience for the whole platform draining by the day.&lt;/p&gt;

&lt;p&gt;I was one bad afternoon away from ripping the app back out of the cluster - and throwing away all the parity we'd just bought - when I came across a write-up on &lt;a href="https://dorokhovich.com/blog/local-k8s-tilt-fast-dev-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-tilt-fast-dev-loop" rel="noopener noreferrer"&gt;using Tilt for a fast local Kubernetes dev loop&lt;/a&gt;. It described the exact pain I was drowning in, and it gave me a way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-line Tiltfile that started it
&lt;/h2&gt;

&lt;p&gt;Tilt's config is a file called &lt;code&gt;Tiltfile&lt;/code&gt; (no extension) written in Starlark, which is basically a trimmed-down Python. Run &lt;code&gt;tilt up&lt;/code&gt; and it executes top to bottom, builds a graph of what to build and deploy, then watches your files and rebuilds only what changed. My first working version was almost embarrassingly short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Tiltfile
&lt;/span&gt;
&lt;span class="c1"&gt;# How to build the myapp image from the current directory
&lt;/span&gt;&lt;span class="nf"&gt;docker_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;k3d-registry.localhost:5000/myapp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# What to deploy — our existing manifests
&lt;/span&gt;&lt;span class="nf"&gt;k8s_yaml&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;k8s/deployment.yaml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;k8s/service.yaml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# Fine-tuning: forward the container's port 8080 to localhost:8080
&lt;/span&gt;&lt;span class="nf"&gt;k8s_resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;myapp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port_forwards&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8080:8080&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three functions carry almost any Tiltfile, all documented in &lt;a href="https://docs.tilt.dev/api.html" rel="noopener noreferrer"&gt;Tilt's API reference&lt;/a&gt;: &lt;code&gt;docker_build&lt;/code&gt; for &lt;em&gt;how&lt;/em&gt; to build, &lt;code&gt;k8s_yaml&lt;/code&gt; for &lt;em&gt;what&lt;/em&gt; to deploy, and &lt;code&gt;k8s_resource&lt;/code&gt; for the fine-tuning like port-forwards and grouping. The clever bit is how Tilt ties them together - it scans the YAML, finds the workload, matches the built image to the manifest &lt;em&gt;by tag&lt;/em&gt;, swaps in a uniquely-tagged fresh build, and deploys. The one rule I had to internalize immediately: the tag in &lt;code&gt;docker_build&lt;/code&gt; must exactly match the &lt;code&gt;image:&lt;/code&gt; in &lt;code&gt;deployment.yaml&lt;/code&gt;. Mismatch it and Tilt cheerfully builds one image while the Deployment asks for another - instant &lt;code&gt;ImagePullBackOff&lt;/code&gt;, and a confusing ten minutes before you realize what you did.&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;tilt up&lt;/code&gt;, my service was live at &lt;code&gt;localhost:8080&lt;/code&gt; with no manual &lt;code&gt;kubectl port-forward&lt;/code&gt;. Already better. But the real prize came next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live Update: the feature that gave me my day back
&lt;/h2&gt;

&lt;p&gt;This is the part that actually changed things. The normal rebuild-image-then-redeploy-pod cycle takes tens of seconds even for a tiny service - Docker layers, registry push, cluster pull, pod restart, all of it. &lt;strong&gt;Live Update&lt;/strong&gt; skips the whole dance: instead of rebuilding, Tilt copies changed files &lt;em&gt;directly into the running container&lt;/em&gt; and, if needed, runs commands there. Seconds, not minutes.&lt;/p&gt;

&lt;p&gt;You configure it inside &lt;code&gt;docker_build&lt;/code&gt; via &lt;code&gt;live_update&lt;/code&gt;, and the steps run in a strict order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;docker_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;k3d-registry.localhost:5000/myapp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;live_update&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;# if deps change, just rebuild the whole image
&lt;/span&gt;        &lt;span class="nf"&gt;fall_back_on&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;requirements.txt&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="c1"&gt;# sync code instantly into /code/app (the WORKDIR from our Dockerfile)
&lt;/span&gt;        &lt;span class="nf"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./app&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/code/app&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="c1"&gt;# reinstall deps only if that file changed
&lt;/span&gt;        &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pip install -r requirements.txt&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trigger&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;requirements.txt&lt;/span&gt;&lt;span class="sh"&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;Tilt's decision tree is refreshingly simple, and &lt;a href="https://docs.tilt.dev/live_update_reference.html" rel="noopener noreferrer"&gt;the Live Update reference&lt;/a&gt; spells out the exact ordering: a &lt;code&gt;fall_back_on&lt;/code&gt; file changed means a full rebuild; a file matched by a &lt;code&gt;sync&lt;/code&gt; gets a fast live update; a file in the context but covered by no &lt;code&gt;sync&lt;/code&gt; triggers a full &lt;code&gt;docker build&lt;/code&gt;; an untracked file does nothing. Two rules tripped me up until I read them twice. &lt;code&gt;sync&lt;/code&gt; paths must live inside the build context - if Tilt is watching it, you can sync it - and &lt;code&gt;run()&lt;/code&gt; cannot come before &lt;code&gt;sync()&lt;/code&gt;, because you have to put the files in place before you act on them. Also worth knowing: the very first deploy is always a full one, since Live Update needs an already-running container to copy into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hot reload closes the loop
&lt;/h2&gt;

&lt;p&gt;Live Update drops the new file into the container, but how does the running process actually pick it up? For us, delightfully, FastAPI via uvicorn hot-reloads on its own. The launch command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uvicorn app.main:app &lt;span class="nt"&gt;--host&lt;/span&gt; 0.0.0.0 &lt;span class="nt"&gt;--port&lt;/span&gt; 8080 &lt;span class="nt"&gt;--reload&lt;/span&gt; &lt;span class="nt"&gt;--reload-dir&lt;/span&gt; app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per &lt;a href="https://github.com/Kludex/uvicorn/blob/main/docs/settings.md" rel="noopener noreferrer"&gt;uvicorn's settings docs&lt;/a&gt;, &lt;code&gt;--reload&lt;/code&gt; runs an internal watcher that restarts the process when &lt;code&gt;.py&lt;/code&gt; files change, and &lt;code&gt;--reload-dir app&lt;/code&gt; narrows the watch so it doesn't twitch on every temp file. So when your framework self-reloads, &lt;code&gt;sync&lt;/code&gt; alone is enough: files land in the container, uvicorn's watcher notices, the app reloads, and there's nothing else to wire up.&lt;/p&gt;

&lt;p&gt;If your stack &lt;em&gt;can't&lt;/em&gt; hot-reload - a Go binary, or uvicorn without &lt;code&gt;--reload&lt;/code&gt; - the synced files just sit there while the old process runs stale code, which is the classic "my changes didn't apply" trap that'll cost you an afternoon of confusion. For that case, &lt;a href="https://github.com/tilt-dev/tilt-extensions/tree/master/restart_process" rel="noopener noreferrer"&gt;Tilt's &lt;code&gt;restart_process&lt;/code&gt; extension&lt;/a&gt; gives you &lt;code&gt;docker_build_with_restart&lt;/code&gt;, which re-runs your entrypoint after each sync. For our FastAPI service plain &lt;code&gt;--reload&lt;/code&gt; was simpler, and since it's dev-only, we never ship &lt;code&gt;--reload&lt;/code&gt; in the production image.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dashboard I didn't know I needed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tilt up&lt;/code&gt; also brings up a web UI at &lt;code&gt;localhost:10350&lt;/code&gt;, and it quietly ended my tab-juggling. On one screen I get every resource with two statuses - did the build and deploy succeed, and what is the pod doing &lt;em&gt;right now&lt;/em&gt; - along with the Pod ID behind a copy button, endpoints as clickable links, and the part I use constantly: filterable logs, by build versus runtime, by level, by keyword. When a pod won't start, I stopped &lt;code&gt;grep&lt;/code&gt;-ing through &lt;code&gt;kubectl logs&lt;/code&gt; across three terminals and just filtered the dashboard. There's a Trigger Update button for manual rebuilds, and a manual mode if you'd rather save often and deploy on demand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;trigger_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TRIGGER_MODE_MANUAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;k8s_resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;myapp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trigger_mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TRIGGER_MODE_AUTO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# but keep this one automatic
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Is Tilt the only option? My honest take
&lt;/h2&gt;

&lt;p&gt;Tilt isn't alone here - it usually gets compared with Skaffold, from Google, and DevSpace. All three build, deploy, and sync for a fast loop, and the real differences come down to UI-first versus CLI-first and Starlark versus YAML. Tilt bets on the visual dashboard and Live Update; the Starlark config is flexible but a steeper climb than YAML, and it shines for beginners and mixed-experience teams precisely because you can &lt;em&gt;see&lt;/em&gt; the whole cluster state. &lt;a href="https://skaffold.dev/docs/filesync/" rel="noopener noreferrer"&gt;Skaffold&lt;/a&gt; is CLI-only with YAML config, file sync, profiles, and a dedicated &lt;code&gt;skaffold debug&lt;/code&gt; - familiar and declarative, but no visual panel. &lt;a href="https://www.devspace.sh/docs/configuration/dev/connections/file-sync" rel="noopener noreferrer"&gt;DevSpace&lt;/a&gt; is a YAML CLI with two-way sync, reverse port-forward, and dev containers.&lt;/p&gt;

&lt;p&gt;If your team lives in YAML and loves the terminal, Skaffold or DevSpace is a great fit, and I won't pretend that choice is anything but subjective. For getting a newcomer developing comfortably on Kubernetes, though, the dashboard won it for me - less blind fumbling in the terminal, more actually understanding what's happening.&lt;/p&gt;

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

&lt;p&gt;The per-edit time went from a minute or two down to a second or two, and the list of things I run by hand collapsed from build-push-restart-logs to simply saving the file. The image only rebuilds when dependencies change now, not on every keystroke. Logs and status live on one dashboard instead of scattered across N terminals. The port-forward is declared once in the Tiltfile instead of manually re-established every time a pod restarts. And my focus, which used to get shredded on every deploy, stays intact.&lt;/p&gt;

&lt;p&gt;The arithmetic that sold me was blunt: forty edits times roughly ninety seconds saved is about an hour a day I got back, and that's before you count the context-switching tax, which I suspect was the bigger number anyway. The whole setup took a single afternoon.&lt;/p&gt;

&lt;p&gt;What lingers, though, isn't the hour. It's that the pain was never Kubernetes - it was my &lt;em&gt;loop&lt;/em&gt;. A tiny Tiltfile, Live Update, and uvicorn's &lt;code&gt;--reload&lt;/code&gt; turned the whole experience from molasses back into the tight edit-run rhythm I'd been quietly grieving, and I got to keep cluster parity while doing it. If you're still rebuilding images by hand and watching progress bars, that's the trade I'd make again in a heartbeat: an afternoon of setup for a loop that finally respects your attention.&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.tilt.dev/api.html" rel="noopener noreferrer"&gt;Tilt docs — API reference (&lt;code&gt;docker_build&lt;/code&gt;, &lt;code&gt;k8s_yaml&lt;/code&gt;, &lt;code&gt;k8s_resource&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.tilt.dev/live_update_reference.html" rel="noopener noreferrer"&gt;Tilt docs — Live Update reference (&lt;code&gt;fall_back_on&lt;/code&gt; / &lt;code&gt;sync&lt;/code&gt; / &lt;code&gt;run&lt;/code&gt; ordering)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/tilt-dev/tilt-extensions/tree/master/restart_process" rel="noopener noreferrer"&gt;Tilt &lt;code&gt;restart_process&lt;/code&gt; extension (&lt;code&gt;docker_build_with_restart&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Kludex/uvicorn/blob/main/docs/settings.md" rel="noopener noreferrer"&gt;Uvicorn — settings (&lt;code&gt;--reload&lt;/code&gt;, &lt;code&gt;--reload-dir&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://skaffold.dev/docs/filesync/" rel="noopener noreferrer"&gt;Skaffold docs — File Sync&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.devspace.sh/docs/configuration/dev/connections/file-sync" rel="noopener noreferrer"&gt;DevSpace docs — Configure File Synchronization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-tilt-fast-dev-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-tilt-fast-dev-loop" rel="noopener noreferrer"&gt;A full Tilt fast-dev-loop walkthrough someone put together&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>docker</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>The Kubernetes Inner Dev Loop That Was Quietly Eating My Sprint - and How I Got Back to Seconds</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:05:16 +0000</pubDate>
      <link>https://dev.to/mnvasil/the-kubernetes-inner-dev-loop-that-was-quietly-eating-my-sprint-and-how-i-got-back-to-seconds-36eh</link>
      <guid>https://dev.to/mnvasil/the-kubernetes-inner-dev-loop-that-was-quietly-eating-my-sprint-and-how-i-got-back-to-seconds-36eh</guid>
      <description>&lt;p&gt;For about six months, my Kubernetes inner dev loop was a productivity black hole, and I mean that almost literally: time went in and nothing came out. I was the backend person on a small payments squad, and I got to the point where I quietly dreaded touching the cluster at all. This is the story of what was actually wrong, why it bugged me for so long, and the setup that finally gave me back a dev loop measured in seconds instead of minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bugged me for so long
&lt;/h2&gt;

&lt;p&gt;Our service - I'll just call it &lt;code&gt;myapp&lt;/code&gt;, a FastAPI HTTP API on port 8080 backed by PostgreSQL - ran beautifully on a laptop. &lt;code&gt;uvicorn --reload&lt;/code&gt; picked up every change in a fraction of a second, the way good local dev should feel. Then we "just" shipped it to Kubernetes, and the instant loop turned into 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;# 1. build the image&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; registry.internal/myapp:dev &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="c"&gt;# 2. push to the registry&lt;/span&gt;
docker push registry.internal/myapp:dev
&lt;span class="c"&gt;# 3. apply the manifests&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; k8s/
&lt;span class="c"&gt;# 4. wait for the new Pod and check it's alive&lt;/span&gt;
kubectl rollout status deployment/myapp
kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; deployment/myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four steps that simply did not exist when we ran things locally. Every single pass was two to five minutes of build, push, wait, check. Multiply that by dozens of iterations a day across the team and you get whole afternoons of people staring at &lt;code&gt;kubectl rollout status&lt;/code&gt;. And here's the part that really got under my skin: the wait was juuust long enough that I'd flip to Slack every time. So the true cost was never the four minutes. It was the train of thought that derailed on every deploy, and never quite got back on the rails.&lt;/p&gt;

&lt;p&gt;A slow loop is only half the misery, though. The other half is the bugs you genuinely cannot see on a laptop. We shipped a change that passed every local test and then watched the Pod get &lt;strong&gt;OOMKilled&lt;/strong&gt; in staging, because we'd never set a memory limit locally - memory is effectively infinite on a dev box. Another release sat there "running but not responding" because a &lt;strong&gt;readiness probe&lt;/strong&gt; was failing and Kubernetes had quietly yanked the Pod out of the Service endpoints. Nothing in my local world had prepared me for either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea that reframed everything: shift-left, but with fidelity
&lt;/h2&gt;

&lt;p&gt;I got tired of band-aids and went looking for a systematic answer. Someone had written up &lt;a href="https://dorokhovich.com/blog/local-k8s-inner-dev-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-inner-dev-loop" rel="noopener noreferrer"&gt;exactly why the Kubernetes inner loop gets slow and how to close the local-versus-cluster gap&lt;/a&gt;, and it put a name to the thing I'd been feeling in my gut: the more your test environment differs from prod, the more bugs leak downstream, where each one costs an order of magnitude more to fix.&lt;/p&gt;

&lt;p&gt;The line that really stuck with me was this - testing earlier in a CI container that doesn't match your cluster &lt;em&gt;isn't&lt;/em&gt; shift-left. It's just failing faster in the wrong environment. Real shift-left for Kubernetes means validating against real cluster conditions: real resource limits, real probes, real services in the namespace, early, on your own machine. That reframed the whole problem for me, and it pointed at two concrete tools instead of a vague "do better."&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;k3d&lt;/strong&gt;, &lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;a lightweight wrapper that runs CNCF's k3s inside Docker&lt;/a&gt;. Not an emulation of a cluster - an actual one, small enough to live on a laptop. The second is &lt;strong&gt;Tilt&lt;/strong&gt;, whose &lt;a href="https://docs.tilt.dev/tutorial/5-live-update.html" rel="noopener noreferrer"&gt;live-update performs an in-place update of the containers in your cluster&lt;/a&gt;, syncing your code straight into a running Pod so iteration drops back to seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually rolled out
&lt;/h2&gt;

&lt;p&gt;I named the cluster &lt;code&gt;dev&lt;/code&gt;, put everything in a &lt;code&gt;myapp&lt;/code&gt; namespace, and used k3d's built-in registry at &lt;code&gt;k3d-registry.localhost:5000&lt;/code&gt;. My first lesson landed the hard way: &lt;strong&gt;the local cluster cannot see images from your local Docker daemon.&lt;/strong&gt; I built &lt;code&gt;myapp:dev&lt;/code&gt;, expected the cluster to pick it up, and got a Pod wedged in &lt;code&gt;ImagePullBackOff&lt;/code&gt; instead. k3d nodes run their own containerd, isolated from your Docker. You either push to a registry the cluster can reach or import the image explicitly - the &lt;a href="https://k3d.io/stable/usage/registries/" rel="noopener noreferrer"&gt;k3d registries guide&lt;/a&gt; walks through wiring up a local registry the cluster can actually pull from.&lt;/p&gt;

&lt;p&gt;The second lesson was the &lt;code&gt;:latest&lt;/code&gt; tag trap. If your manifest references &lt;code&gt;myapp:latest&lt;/code&gt;, Kubernetes defaults &lt;code&gt;imagePullPolicy&lt;/code&gt; to &lt;code&gt;Always&lt;/code&gt; and the kubelet re-pulls on every launch even when the image is sitting right there. &lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;The Kubernetes image docs spell it out&lt;/a&gt;: omit &lt;code&gt;imagePullPolicy&lt;/code&gt; with a &lt;code&gt;:latest&lt;/code&gt; tag and it becomes &lt;code&gt;Always&lt;/code&gt;, while a fixed tag defaults to &lt;code&gt;IfNotPresent&lt;/code&gt;. I switched to specific tags - &lt;code&gt;myapp:dev&lt;/code&gt;, &lt;code&gt;myapp:&amp;lt;gitsha&amp;gt;&lt;/code&gt; - and &lt;code&gt;imagePullPolicy: IfNotPresent&lt;/code&gt;, and a whole category of mysterious slowness just evaporated.&lt;/p&gt;

&lt;p&gt;Then Tilt took over the loop, and this is the part I still find a little magical. Instead of build then push then apply then wait, saving a file synced the change straight into the running Pod. The numbers matched exactly what the tool promised - Tilt's own writeup frames live-update as &lt;a href="https://blog.tilt.dev/2019/04/02/fast-kubernetes-development-with-live-update.html" rel="noopener noreferrer"&gt;deploying code to running containers in seconds, not minutes&lt;/a&gt;, and syncing files into a running Pod with hot reload brought my iteration back down to roughly one to five seconds, better than a 95% cut. One detail I'm glad I got right: in the cluster image I run &lt;code&gt;fastapi run&lt;/code&gt;, &lt;em&gt;not&lt;/em&gt; &lt;code&gt;uvicorn --reload&lt;/code&gt;. Reload is dev-only overhead and a leak risk; the speed comes from Tilt's live-update, not from reload inside the container.&lt;/p&gt;

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

&lt;p&gt;The headline is easy to quote - two-to-five-minute iterations became one-to-five-second ones, and "build, push, apply, wait, check" collapsed into "save the file." But honestly the second-order effects were the ones that changed how I work. Because a change now took seconds to see, I started testing &lt;em&gt;smaller&lt;/em&gt; increments, one hypothesis at a time, instead of batching five changes into a single painful deploy and then bisecting which one broke. Pull requests got smaller. Review comments got sharper, because a reviewer could pull the branch and watch it run in a real cluster in under a minute.&lt;/p&gt;

&lt;p&gt;And the OOMKilled and readiness-probe bugs that used to ambush us in staging now surfaced on the author's laptop, where the person with all the context was sitting right there to fix them. That's the whole economic argument for shift-left in one sentence: a bug caught in the inner loop costs a coffee's worth of attention, while the same bug caught after &lt;code&gt;git push&lt;/code&gt; costs a CI run, a reviewer's time, and sometimes a rollback. I didn't measure it to the decimal, but the number of "why is staging broken?" Slack threads dropped to nearly zero within a sprint, and I stopped bracing every time I opened the cluster.&lt;/p&gt;

&lt;p&gt;A few things bit hard enough to cost a day each, and I'll pass them on so they don't cost you one. &lt;code&gt;ImagePullBackOff&lt;/code&gt; on day one had nothing to do with a bad image - the cluster just couldn't see it, and understanding image delivery into k3d up front would have saved me an afternoon. Running &lt;code&gt;--reload&lt;/code&gt; inside the container "because it was fast locally" fought Tilt and masked the real loop, so kill it. Don't confuse the inner and outer loops: this whole exercise is about the &lt;em&gt;inner&lt;/em&gt; loop, the single-developer edit-build-run cycle before &lt;code&gt;git push&lt;/code&gt;, and the outer loop of CI, GitOps, and integration tests is a genuinely separate beast. And don't skip resource limits locally - the entire point of a production-like local setup is that limits and probes &lt;em&gt;exist&lt;/em&gt; on your laptop, so leaving them out just reopens the gap you were trying to close.&lt;/p&gt;

&lt;p&gt;I had to actively resist the itch to also "fix" that outer loop in the same push. It's real work, but it's a different problem with different tools, and bundling it would have stalled everything. I fixed the inner loop first, shipped it, and let the win speak before touching anything downstream. Once the loop was fast, I layered the rest of a production-like setup on piece by piece - real Deployment and Service manifests, PostgreSQL inside the cluster, ConfigMaps and Secrets instead of hardcoded values, health probes - one production-like layer at a time (there's a full end-to-end assembly linked in Sources below).&lt;/p&gt;

&lt;p&gt;What I keep coming back to isn't the speed number, satisfying as it is. It's that I started &lt;em&gt;using&lt;/em&gt; the cluster again instead of avoiding it. A dev loop you dread is a dev loop you route around, and every workaround quietly costs you the fidelity you were supposed to be buying. Getting seconds back didn't just make me faster - it made me willing, and that turned out to be the whole point.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;k3d-io/k3d — &lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;Little helper to run CNCF's k3s in Docker&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;k3d docs — &lt;a href="https://k3d.io/stable/usage/registries/" rel="noopener noreferrer"&gt;Using image registries with k3d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tilt docs — &lt;a href="https://docs.tilt.dev/tutorial/5-live-update.html" rel="noopener noreferrer"&gt;Smart Rebuilds with Live Update&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tilt blog — &lt;a href="https://blog.tilt.dev/2019/04/02/fast-kubernetes-development-with-live-update.html" rel="noopener noreferrer"&gt;Fast Kubernetes Development with Live Update&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Kubernetes docs — &lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;Container Images &amp;amp; imagePullPolicy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/local-k8s-inner-dev-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-inner-dev-loop" rel="noopener noreferrer"&gt;A full local-Kubernetes inner-loop write-up someone put together&lt;/a&gt;, covering the end-to-end k3d-to-production-like assembly&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <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>
