<?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>ImagePullBackOff in k3d even though `docker build` succeeded? Here's why (a k8s error field guide)</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Wed, 16 Sep 2026 11:01:56 +0000</pubDate>
      <link>https://dev.to/mnvasil/imagepullbackoff-in-k3d-even-though-docker-build-succeeded-heres-why-a-k8s-error-field-guide-oj9</link>
      <guid>https://dev.to/mnvasil/imagepullbackoff-in-k3d-even-though-docker-build-succeeded-heres-why-a-k8s-error-field-guide-oj9</guid>
      <description>&lt;p&gt;The most disorienting beginner moment in k3d: you build an image with &lt;code&gt;docker build&lt;/code&gt;, the cluster says &lt;code&gt;ImagePullBackOff&lt;/code&gt;, and you're sure the image exists. It does — in Docker. &lt;strong&gt;k3d nodes run on containerd, which is isolated from your Docker daemon.&lt;/strong&gt; Docker has the image; the cluster can't see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Almost every status is diagnosed with the same three commands: &lt;code&gt;kubectl describe pod&lt;/code&gt; (State/Reason/Events), &lt;code&gt;kubectl logs --previous&lt;/code&gt;, &lt;code&gt;kubectl get events --sort-by=.lastTimestamp&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ImagePullBackOff&lt;/strong&gt;: typo in image/tag, private registry without &lt;code&gt;imagePullSecrets&lt;/code&gt;, Docker Hub rate limit (&lt;code&gt;toomanyrequests&lt;/code&gt;), or the k3d containerd isolation. Fix the last with &lt;code&gt;k3d image import myapp:dev -c dev&lt;/code&gt;, or (better) a local registry — and reference the &lt;strong&gt;full&lt;/strong&gt; name &lt;code&gt;k3d-registry.localhost:5000/myapp:dev&lt;/code&gt; in the manifest. An incomplete image name is a top cause of a repeat BackOff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CrashLoopBackOff&lt;/strong&gt;: &lt;code&gt;kubectl logs --previous&lt;/code&gt; for the dead instance's real error; check &lt;code&gt;Last State: Terminated&lt;/code&gt; Exit Code. Common: app bug at startup, missing env/config, unavailable dependency, OOM, too-strict liveness, or exit code 0 (a long-running service that "exited successfully" — usually a wrong entrypoint).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending&lt;/strong&gt;: &lt;code&gt;FailedScheduling&lt;/code&gt; names the cause — usually insufficient CPU/memory for &lt;code&gt;requests&lt;/code&gt; on a small local cluster. Also nodeSelector/affinity, taints, unbound PVC, hostPort clash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OOMKilled (exit 137 = 128+9)&lt;/strong&gt;: container-level (exceeded &lt;code&gt;limits.memory&lt;/code&gt;) or the sneaky &lt;strong&gt;node-level OOM&lt;/strong&gt; — k3d nodes live in a Docker VM, so combined limits over the VM's memory kill Pods even when each app is within its own limit. Don't overcommit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service silent, Pods Running&lt;/strong&gt;: walk &lt;code&gt;Service -&amp;gt; endpoints -&amp;gt; Pod&lt;/code&gt;. Empty endpoints = selector mismatch (labels are case-sensitive) or port mismatch (&lt;code&gt;targetPort&lt;/code&gt; vs &lt;code&gt;containerPort&lt;/code&gt;). &lt;code&gt;Running 0/1&lt;/code&gt; = readiness failing (removed from endpoints, not restarted).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tilt not updating&lt;/strong&gt;: synced path outside build context, file not covered by &lt;code&gt;sync()&lt;/code&gt;, &lt;code&gt;fall_back_on&lt;/code&gt; file changed (forces rebuild), or &lt;code&gt;run()&lt;/code&gt; before &lt;code&gt;sync()&lt;/code&gt;. FastAPI trap: without a process restart the synced code lands but uvicorn keeps the old code in memory — use &lt;code&gt;--reload&lt;/code&gt; or &lt;code&gt;docker_build_with_restart&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk eaten&lt;/strong&gt;: kubelet image GC is lazy (only above 85% disk). &lt;code&gt;docker system df&lt;/code&gt;, then prune incrementally — but &lt;code&gt;-a&lt;/code&gt; can wipe images the cluster needs and &lt;code&gt;--volumes&lt;/code&gt; can delete your local PostgreSQL data.&lt;/li&gt;
&lt;/ul&gt;

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

</description>
      <category>kubernetes</category>
      <category>k3d</category>
      <category>docker</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 15: Common problems and how to fix them</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Mon, 14 Sep 2026 09:58:16 +0000</pubDate>
      <link>https://dev.to/mnvasil/local-kubernetes-dev-part-15-common-problems-and-how-to-fix-them-2i99</link>
      <guid>https://dev.to/mnvasil/local-kubernetes-dev-part-15-common-problems-and-how-to-fix-them-2i99</guid>
      <description>&lt;p&gt;"I built the image with docker build, but in k3d it's ImagePullBackOff. How? The image exists!"&lt;/p&gt;

&lt;p&gt;Because k3d nodes run on containerd, and it's isolated from your Docker daemon. Docker has the image, the cluster doesn't. The most disorienting moment for a k3d newcomer, and it's fixed two ways: k3d image import, or (better) a local registry with the full name k3d-registry.localhost:5000/myapp:dev in the manifest.&lt;/p&gt;

&lt;p&gt;In the new article (part 15) — a field guide to the statuses you hit every week, all diagnosed with the same three commands:&lt;br&gt;
• ImagePullBackOff / ErrImagePull (+ the k3d containerd trap);&lt;br&gt;
• CrashLoopBackOff (and why logs --previous is the key);&lt;br&gt;
• Pending / FailedScheduling;&lt;br&gt;
• OOMKilled (137) — and the sneaky node-level OOM inside the Docker VM;&lt;br&gt;
• Service is silent: a break in the Service → endpoints → Pod chain (selector / ports / readiness);&lt;br&gt;
• Tilt not picking up changes;&lt;br&gt;
• k3d/Docker ate the disk.&lt;/p&gt;

&lt;p&gt;Bookmark it: &lt;a href="https://dorokhovich.com/blog/local-k8s-troubleshooting?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-troubleshooting" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-troubleshooting?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-troubleshooting&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>k3d</category>
      <category>docker</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why `kubectl apply` doesn't roll out your `:latest` image (and the bridge from local to CI)</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Sat, 12 Sep 2026 20:26:02 +0000</pubDate>
      <link>https://dev.to/mnvasil/why-kubectl-apply-doesnt-roll-out-your-latest-image-and-the-bridge-from-local-to-ci-3bii</link>
      <guid>https://dev.to/mnvasil/why-kubectl-apply-doesnt-roll-out-your-latest-image-and-the-bridge-from-local-to-ci-3bii</guid>
      <description>&lt;p&gt;The counterintuitive pipeline bug: you push a new image under the same &lt;code&gt;:latest&lt;/code&gt; tag, run &lt;code&gt;kubectl apply&lt;/code&gt;, and nothing deploys. Kubernetes only triggers a rollout when the &lt;strong&gt;pod template changes&lt;/strong&gt; — the line &lt;code&gt;image: myapp:latest&lt;/code&gt; didn't change, so as far as the cluster is concerned there's nothing to do, even though the registry image is now different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;latest&lt;/code&gt; is three problems in one:&lt;/strong&gt; it's mutable (no reproducibility); re-applying doesn't roll out (template unchanged); and a Pod restart may silently pull a &lt;em&gt;different&lt;/em&gt;, possibly broken image. Prod changes version "by itself."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use immutable tags&lt;/strong&gt; that point to one specific build: short SHA (&lt;code&gt;sha-abc1234&lt;/code&gt;), semver (&lt;code&gt;v1.2.3&lt;/code&gt;), or a digest (&lt;code&gt;@sha256:...&lt;/code&gt;) for maximum reproducibility. In CI, &lt;code&gt;docker/metadata-action&lt;/code&gt; generates these from git context and writes OCI labels (&lt;code&gt;org.opencontainers.image.revision&lt;/code&gt;, etc.) tying the image to a commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One base, per-environment overlays&lt;/strong&gt; to avoid config drift: &lt;strong&gt;Helm&lt;/strong&gt; (Go templates + &lt;code&gt;values-prod.yaml&lt;/code&gt;; multiple &lt;code&gt;-f&lt;/code&gt; merge last-wins — order matters) or &lt;strong&gt;Kustomize&lt;/strong&gt; (plain YAML &lt;code&gt;base/&lt;/code&gt; + &lt;code&gt;overlays/&lt;/code&gt;; the &lt;code&gt;images:&lt;/code&gt; field swaps tags without editing the Deployment). Hybrid is common: third-party charts via Helm, your own services via Kustomize.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal push-based CI&lt;/strong&gt; (GitHub Actions): checkout -&amp;gt; buildx -&amp;gt; login -&amp;gt; metadata-action -&amp;gt; build-push-action -&amp;gt; deploy with an &lt;em&gt;immutable&lt;/em&gt; tag (&lt;code&gt;kubectl set image&lt;/code&gt; / &lt;code&gt;helm upgrade&lt;/code&gt; / &lt;code&gt;kustomize build | apply&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be deliberate about the local/prod split.&lt;/strong&gt; Reused as-is: same Dockerfile/image, same charts/base, probes. Prod-only: real registry + immutable tags, HPA/PDB/anti-affinity, real Secrets/TLS, monitoring. Must NOT carry over: Tilt live-update/file-sync, &lt;code&gt;uvicorn --reload&lt;/code&gt;, exposed debug ports — they're a security hole in prod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push-CI's three weaknesses point to GitOps:&lt;/strong&gt; CI holds the cluster keys (attack surface), Git-vs-cluster drift isn't tracked, and there's no automatic rollback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full article: &lt;a href="https://dorokhovich.com/blog/local-k8s-preparing-for-deployment-and-ci?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-preparing-for-deployment-and-ci" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-preparing-for-deployment-and-ci?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-preparing-for-deployment-and-ci&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>cicd</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 14: Preparing for deployment and CI</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Thu, 10 Sep 2026 12:40:35 +0000</pubDate>
      <link>https://dev.to/mnvasil/local-kubernetes-dev-part-14-preparing-for-deployment-and-ci-l8i</link>
      <guid>https://dev.to/mnvasil/local-kubernetes-dev-part-14-preparing-for-deployment-and-ci-l8i</guid>
      <description>&lt;p&gt;"I pushed a new image under the same :latest, ran kubectl apply — and nothing changed in the cluster." Why?&lt;/p&gt;

&lt;p&gt;Kubernetes triggers a rollout only if the pod template changed. The line image: myapp:latest didn't change — so as far as the cluster is concerned there's nothing to deploy, even though the registry already holds a different image. Worse, on a pod restart latest can pull a new (possibly broken) version — on its own, without a deploy from you. Prod changes versions "all by itself."&lt;/p&gt;

&lt;p&gt;In the new article (part 14) — the bridge from a fast local loop to a repeatable deploy:&lt;br&gt;
• one manifest base + per-environment overlays (Helm values vs Kustomize patches), no copy-paste and no config drift;&lt;br&gt;
• why latest in prod is pain, and what to use instead: immutable tags (sha, semver, digest) + docker/metadata-action;&lt;br&gt;
• a minimal CI on GitHub Actions: build → push → apply;&lt;br&gt;
• an explicit breakdown: what moves from local, what's prod-only, and what you must NOT drag into prod (Tilt live-update, uvicorn --reload, debug ports);&lt;br&gt;
• the three weaknesses of push-based CI (keys in CI, drift, manual rollback) — and why they lead to GitOps.&lt;/p&gt;

&lt;p&gt;Read it: &lt;a href="https://dorokhovich.com/blog/local-k8s-preparing-for-deployment-and-ci?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-preparing-for-deployment-and-ci" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-preparing-for-deployment-and-ci?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-preparing-for-deployment-and-ci&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>cicd</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Kubernetes Secrets are base64, not encryption — and 4 more config gotchas</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Thu, 27 Aug 2026 20:38:52 +0000</pubDate>
      <link>https://dev.to/mnvasil/kubernetes-secrets-are-base64-not-encryption-and-4-more-config-gotchas-a8</link>
      <guid>https://dev.to/mnvasil/kubernetes-secrets-are-base64-not-encryption-and-4-more-config-gotchas-a8</guid>
      <description>&lt;p&gt;If you take one thing from this: a &lt;code&gt;Secret&lt;/code&gt; committed to git with a real password is a leaked password. base64 is encoding, not encryption — it reverses with 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="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'czNjcjN0'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;   &lt;span class="c"&gt;# prints: s3cr3t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kubernetes docs say it plainly: Secrets are stored &lt;strong&gt;unencrypted&lt;/strong&gt; in etcd by default, just base64-encoded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ConfigMap vs Secret&lt;/strong&gt;: ConfigMap is for non-sensitive key/value data (1 MiB limit, no secrecy, must share the Pod's namespace). Secret is for passwords/keys/tokens. Use &lt;code&gt;stringData:&lt;/code&gt; so the API base64-encodes values for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three ways to inject values&lt;/strong&gt;: &lt;code&gt;env&lt;/code&gt; + &lt;code&gt;valueFrom&lt;/code&gt; (one key), &lt;code&gt;envFrom&lt;/code&gt; (all keys), or a volume mount (each key becomes a file). The trap: &lt;strong&gt;env is fixed at Pod start and never updates&lt;/strong&gt; on a ConfigMap/Secret change — you must &lt;code&gt;kubectl rollout restart&lt;/code&gt;. A volume mount &lt;em&gt;does&lt;/em&gt; update automatically (minus &lt;code&gt;subPath&lt;/code&gt;), but your app has to re-read the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep secrets out of git&lt;/strong&gt;: minimum is a committed &lt;code&gt;secret.example.yaml&lt;/code&gt; template + real &lt;code&gt;secret.yaml&lt;/code&gt; in &lt;code&gt;.gitignore&lt;/code&gt;. For GitOps, encrypt before committing with Sealed Secrets / SOPS / External Secrets. Sealed Secrets are bound to namespace+name; back up the controller's private key or your committed SealedSecrets become unreadable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-environment values without copy-paste&lt;/strong&gt;: Kustomize &lt;code&gt;configMapGenerator&lt;/code&gt;/&lt;code&gt;secretGenerator&lt;/code&gt; add a content hash suffix to the name — change the contents, the name changes, the Deployment reference updates, and you get an automatic rolling update. Or Helm: one chart + &lt;code&gt;values-dev.yaml&lt;/code&gt;/&lt;code&gt;values-prod.yaml&lt;/code&gt; with only the diffs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What actually protects secrets in prod&lt;/strong&gt;: Encryption at Rest in etcd, plus least-privilege RBAC. Note that &lt;code&gt;list&lt;/code&gt; on secrets implicitly exposes their contents.&lt;/li&gt;
&lt;/ul&gt;

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

</description>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 10: Configuration and secrets</title>
      <dc:creator>Menshikov Vasil</dc:creator>
      <pubDate>Tue, 25 Aug 2026 12:41:10 +0000</pubDate>
      <link>https://dev.to/mnvasil/local-kubernetes-dev-part-10-configuration-and-secrets-4o1b</link>
      <guid>https://dev.to/mnvasil/local-kubernetes-dev-part-10-configuration-and-secrets-4o1b</guid>
      <description>&lt;p&gt;"I'm using a Secret — so my password is protected." Nope.&lt;/p&gt;

&lt;p&gt;A Kubernetes Secret stores values in base64. Many people mistake this for encryption — and happily commit a manifest with a real password to git. But base64 unwraps with a single command:&lt;/p&gt;

&lt;p&gt;echo 'czNjcjN0' | base64 -d   # → s3cr3t&lt;/p&gt;

&lt;p&gt;So a Secret with a real password in your repo = a leaked password. And git remembers everything: delete the file later and the value still lives on in history.&lt;/p&gt;

&lt;p&gt;In the new article I go through it step by step:&lt;br&gt;
• how ConfigMap differs from Secret and why base64 ≠ encryption;&lt;br&gt;
• three ways to pass values into a Pod (env, envFrom, volume) and the trap of "I changed the ConfigMap but the Pod still runs on the old env";&lt;br&gt;
• how to avoid committing a secret: templates + .gitignore for local, Sealed Secrets for prod;&lt;br&gt;
• how to keep different values for dev and prod without copy-pasting manifests (Kustomize secretGenerator / Helm values);&lt;br&gt;
• what actually protects secrets in prod: Encryption at Rest and RBAC (where even list permission on secrets exposes their contents).&lt;/p&gt;

&lt;p&gt;Part 10 of the local Kubernetes series. Read it and stop confusing encoding with encryption: &lt;a href="https://dorokhovich.com/blog/local-k8s-configuration-and-secrets?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-configuration-and-secrets" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-configuration-and-secrets?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-configuration-and-secrets&lt;/a&gt;&lt;/p&gt;

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