<?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: Metronom</title>
    <description>The latest articles on DEV Community by Metronom (@mtrnm).</description>
    <link>https://dev.to/mtrnm</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%2F4037870%2F0250143b-258c-4970-929e-847fa83a53e4.png</url>
      <title>DEV Community: Metronom</title>
      <link>https://dev.to/mtrnm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mtrnm"/>
    <language>en</language>
    <item>
      <title>Every MCP revision, and what each one took back</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Thu, 20 Aug 2026 08:55:34 +0000</pubDate>
      <link>https://dev.to/mtrnm/every-mcp-revision-and-what-each-one-took-back-5fm5</link>
      <guid>https://dev.to/mtrnm/every-mcp-revision-and-what-each-one-took-back-5fm5</guid>
      <description>&lt;p&gt;MCP is twenty months old and has had five specification revisions. I put them all in one table because the shape of the list says more than any single entry does.&lt;/p&gt;

&lt;p&gt;Read the removals, not the additions. Batching arrived in March 2025 and was gone by June, before it was three months old, which tells you it was never load-bearing: nothing had been built on it, so the specification was free to admit the mistake. Sessions and the opening handshake went on 28 July 2026, and that is a different kind of news entirely. A capability deleted in month seven is a correction. A rework of the foundations in month twenty means the foundations were wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Revision&lt;/th&gt;
&lt;th&gt;Added&lt;/th&gt;
&lt;th&gt;Taken back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2024-11-05&lt;/td&gt;
&lt;td&gt;JSON-RPC; stdio and HTTP+SSE; tools, resources, prompts&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-03-26&lt;/td&gt;
&lt;td&gt;OAuth 2.1; Streamable HTTP; batching&lt;/td&gt;
&lt;td&gt;HTTP+SSE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-18&lt;/td&gt;
&lt;td&gt;Structured output&lt;/td&gt;
&lt;td&gt;Batching, under three months old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;Icons; schema defaults; JSON Schema 2020-12&lt;/td&gt;
&lt;td&gt;Nothing the revision list records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;Capability-listing call; "input required"; response cache hints; routing headers&lt;/td&gt;
&lt;td&gt;Sessions; the handshake; stream resumption. Deprecated with a year of grace: server-to-model calls, folder access, protocol-level logging, dynamic client registration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 2026-07-28 row is the one worth arguing about. Removing sessions made MCP stateless, which is Fielding's second REST constraint, adopted twenty months late by a protocol that had started from the opposite premise. Everything else in that revision follows from it: response cache hints, and headers carrying enough routing information that a proxy can direct a request without parsing the body. These are the features you add when your protocol has to survive contact with ordinary infrastructure.&lt;/p&gt;

&lt;p&gt;My reading, and you are welcome to disagree with it: the reversal is late and right. Sessions in a protocol living between two processes owned by different people were a mistake from the beginning. The two ends are deployed, restarted, scaled and upgraded on separate schedules, and a shared memory between them is a liability one side always ends up holding.&lt;/p&gt;

&lt;p&gt;Nothing broke on 28 July. The revision came out as a Release Candidate, the SDKs are in beta, and everything deprecated has a year of grace. The practical consequence is not "wait", it is "budget for rewriting".&lt;/p&gt;

&lt;p&gt;Source: modelcontextprotocol.io, revisions 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 and 2026-07-28, plus the official changelog for the last of them. I keep this table current as revisions land, with per-revision anchors so you can link to a row rather than to a page: &lt;a href="https://dorokhovich.com/blog/mcp-revision-history" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/mcp-revision-history&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One real question: is there a row here you would have filled in differently? The 2025-11-25 line in particular says "nothing the revision list records", and that is a claim about a changelog rather than a line-by-line diff. If you have diffed it, tell me what I missed.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>api</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
    <item>
      <title>Hacker News + dev.to (a concrete '2-5 minutes to a few seconds' DX win is a proven front-page hook)</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:59:47 +0000</pubDate>
      <link>https://dev.to/mtrnm/hacker-news-devto-a-concrete-2-5-minutes-to-a-few-seconds-dx-win-is-a-proven-front-page-hook-1dk9</link>
      <guid>https://dev.to/mtrnm/hacker-news-devto-a-concrete-2-5-minutes-to-a-few-seconds-dx-win-is-a-proven-front-page-hook-1dk9</guid>
      <description>&lt;h1&gt;
  
  
  We got the Kubernetes inner loop from 2–5 minutes back to seconds with Tilt
&lt;/h1&gt;

&lt;p&gt;Chapter 8 of a local-Kubernetes series — the payoff that closes the loop chapter 1 opened. Tilt watches files, rebuilds, deploys, and with Live Update syncs code into a &lt;em&gt;running&lt;/em&gt; container in seconds.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pain, quantified:&lt;/strong&gt; by hand, every edit is &lt;code&gt;docker build → push → kubectl rollout restart → wait → check logs&lt;/code&gt; — a minute or two each. Ten edits = half an hour gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Tiltfile is a program&lt;/strong&gt;, not YAML — it's Starlark (a Python dialect). &lt;code&gt;tilt up&lt;/code&gt; executes it top to bottom; edit the Tiltfile and Tilt re-executes it, nothing to restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The three pillars:&lt;/strong&gt; &lt;code&gt;docker_build('tag','ctx')&lt;/code&gt; (how to build), &lt;code&gt;k8s_yaml('file')&lt;/code&gt; (what to deploy), &lt;code&gt;k8s_resource('name', port_forwards=...)&lt;/code&gt; (fine-tuning). Tilt matches built images to manifest images &lt;strong&gt;by tag&lt;/strong&gt; and swaps in the fresh build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live Update is the star:&lt;/strong&gt; instead of rebuild+redeploy, Tilt copies changed files straight into the running container. Steps run in a strict order — &lt;code&gt;fall_back_on&lt;/code&gt; → &lt;code&gt;sync&lt;/code&gt; → &lt;code&gt;run&lt;/code&gt; → &lt;code&gt;restart_container&lt;/code&gt; — and the order can't be broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two newbie traps:&lt;/strong&gt; &lt;code&gt;sync&lt;/code&gt; paths must live inside the same &lt;code&gt;docker_build&lt;/code&gt; context ("if Tilt is watching it, you can sync it"); &lt;code&gt;run()&lt;/code&gt; can't come before &lt;code&gt;sync()&lt;/code&gt;. Also: the &lt;em&gt;first&lt;/em&gt; deploy is always full — Live Update needs a running container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hot reload, two scenarios:&lt;/strong&gt; (A) framework reloads itself — &lt;code&gt;uvicorn --reload --reload-dir app&lt;/code&gt;, &lt;code&gt;sync&lt;/code&gt; is enough; (B) no reload — use the &lt;code&gt;restart_process&lt;/code&gt; extension (&lt;code&gt;docker_build_with_restart&lt;/code&gt;), the modern replacement for the deprecated &lt;code&gt;restart_container()&lt;/code&gt; on k8s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard at &lt;code&gt;localhost:10350&lt;/code&gt;:&lt;/strong&gt; per-resource update + runtime status, logs with filtering, endpoints as links, manual Trigger Update, optional &lt;code&gt;TRIGGER_MODE_MANUAL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vs Skaffold/DevSpace:&lt;/strong&gt; Tilt = UI-first + Starlark + Live Update; Skaffold/DevSpace = CLI-first + YAML. Fair caveat: pick by team taste.&lt;/li&gt;
&lt;/ul&gt;

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

</description>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 8: Tilt — a fast development loop</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:41:15 +0000</pubDate>
      <link>https://dev.to/mtrnm/local-kubernetes-dev-part-8-tilt-a-fast-development-loop-6dp</link>
      <guid>https://dev.to/mtrnm/local-kubernetes-dev-part-8-tilt-a-fast-development-loop-6dp</guid>
      <description>&lt;p&gt;Remember the loop from chapter one that stretched from seconds to 2–5 minutes on every change? This is the chapter where we bring it back down to seconds.&lt;/p&gt;

&lt;p&gt;Part eight of the series is the emotional climax. Tilt orchestrates your inner loop: it watches files, builds the image for you, deploys to the cluster for you, and — crucially — can update code right inside the running container in seconds. Plus a web dashboard where all your services, statuses, and logs live on one screen, no juggling ten terminals.&lt;/p&gt;

&lt;p&gt;The three pillars of any Tiltfile: &lt;code&gt;docker_build&lt;/code&gt; (how to build), &lt;code&gt;k8s_yaml&lt;/code&gt; (what to deploy), &lt;code&gt;k8s_resource&lt;/code&gt; (fine-tuning — port forwarding). The magic is that Tilt matches images from docker_build to images in the manifests by tag and substitutes the fresh build. The star of the show is Live Update: instead of "rebuild image → redeploy pod," Tilt copies the changed files straight into the live container. With FastAPI it pairs beautifully with &lt;code&gt;uvicorn --reload&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I break down the strict order of live_update steps (fall_back_on → sync → run → restart), two beginner traps (sync only within the build context; run can't come before sync), the no-hot-reload path via restart_process, and an honest comparison of Tilt vs Skaffold vs DevSpace. &lt;a href="https://dorokhovich.com/blog/local-k8s-tilt-fast-dev-loop?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-tilt-fast-dev-loop" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-tilt-fast-dev-loop?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-tilt-fast-dev-loop&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>What GitHub's MCP Server Costs You Before You Ask It Anything</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:02:21 +0000</pubDate>
      <link>https://dev.to/mtrnm/sindikatsiia-devto-mcp-vs-rest-the-bill-comes-in-tokens-2agb</link>
      <guid>https://dev.to/mtrnm/sindikatsiia-devto-mcp-vs-rest-the-bill-comes-in-tokens-2agb</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;br&gt;
GitHub's official MCP server spends roughly 17,600 tokens on tool descriptions before you ask it anything, with every toolset switched on.&lt;br&gt;
The same operation through MCP costs 4 to 32 times what the equivalent command-line call costs.&lt;br&gt;
If you can write the list of calls in advance, write it, and keep REST underneath.&lt;/p&gt;

&lt;p&gt;Attach GitHub's official MCP server to an assistant, every toolset switched on, and the conversation opens with a bill of roughly 17,600 tokens. Nobody has asked anything yet. That is the cost of the tool descriptions alone — the names, the sentence explaining what each tool does, the shape of the arguments it expects — and it is paid again on every request for as long as the server stays attached. The figure is a count of the server's own tool schemas, published by people who counted them; it is not a vendor's number.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Written 5 August 2026.&lt;/strong&gt; Every figure below was measured between late 2025 and July 2026, before the revision of 28 July changed some of the rules they were measured under. The prices move; the arithmetic does not. Measure on your own workload before you plan around them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to be careful with that number, because it belongs to a configuration rather than to a product. The server groups its tools into toolsets you can switch off, and switching the unused ones off cuts the figure several times over. Which is exactly the point: nobody can honestly quote it without saying which toolsets were on. In the measurement above, all of them were.&lt;/p&gt;

&lt;p&gt;The "MCP versus REST" argument is almost always conducted as though it were a matter of taste, settled by whichever side sounds more modern. It is not a matter of taste. There is a price list, the numbers on it are fairly harsh, and once you have seen them the choice stops being about architecture and starts being about whether the thing you are building actually needs a model to decide anything.&lt;/p&gt;

&lt;p&gt;Where the numbers come from, before anyone asks in the comments: public analyses of the GitHub MCP Server and survey write-ups of context overhead from late 2025 and early 2026, plus the schema-overhead discussion in issue #2808 of the specification repository. Specification revisions used: 2025-06-18 and 2026-07-28. The truncation incident later in this piece is from my own publishing server, audited on 25 July 2026 — that part is not a citation, it is my own damage.&lt;/p&gt;

&lt;p&gt;FULL ARTICLE TEXT — syndicate from &lt;a href="https://dorokhovich.com/blog/mcp-token-cost" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/mcp-token-cost&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything from the section "Start with the bill" to the end of "How to cut the MCP token bill" goes across unchanged. Forem renders GFM, so nothing needs converting. The truncation incident is now its own section, "The cost that appears on no invoice", second in the article rather than buried three headings down — do not move it back.&lt;/p&gt;

&lt;p&gt;CLOSING (replaces the blog's series-navigation block)&lt;/p&gt;

&lt;p&gt;The three articles this comes from are on my own site: &lt;a href="https://dorokhovich.com/blog/mcp-vs-rest-api" rel="noopener noreferrer"&gt;where MCP and REST came from&lt;/a&gt;, this one on &lt;a href="https://dorokhovich.com/blog/mcp-token-cost" rel="noopener noreferrer"&gt;what MCP costs in tokens&lt;/a&gt;, and &lt;a href="https://dorokhovich.com/blog/how-mcp-works" rel="noopener noreferrer"&gt;what an MCP server is actually made of&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One real question, and I want the disagreement rather than the agreement: have you measured what your own tool catalogue costs per request? Not the vendor's number for their server — yours, counted on your own descriptions. I have not published mine, and that is a fair thing to hold against this article: I run a server with twenty tools and I have quoted somebody else's figure rather than my own. If you have counted yours, I would like the number and the method, because the method is the part nobody publishes.&lt;/p&gt;

&lt;p&gt;ADAPTATION NOTES&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep the GFM tables. Forem renders them; this is the platform where the comparison table earns its place.&lt;/li&gt;
&lt;li&gt;The dated callout does not go at the top here. Forem builds the feed and share card from the first ~140 characters, so a callout in that position puts a caveat where the hook belongs. It is already placed above, immediately after the opening paragraph, as an ordinary blockquote; dev.to renders it fine there.&lt;/li&gt;
&lt;li&gt;Images: the three PNGs need re-uploading to Forem or referencing by absolute URL; relative &lt;code&gt;/images/blog/...&lt;/code&gt; paths will 404 on dev.to. Check every one after publishing rather than assuming.&lt;/li&gt;
&lt;li&gt;After publishing, spend the following 48 hours in the comments under my own name. Since mutual claps and cross-profile upvoting are off the table as manipulation, replying in comments is the only distribution channel this campaign has that costs nothing and is honest.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mcp</category>
      <category>api</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
    <item>
      <title>Kubernetes ImagePullBackOff, CrashLoopBackOff, OOMKilled: The 3-Command Troubleshooting Playbook</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Wed, 12 Aug 2026 12:32:08 +0000</pubDate>
      <link>https://dev.to/mtrnm/kubernetes-imagepullbackoff-crashloopbackoff-oomkilled-the-3-command-troubleshooting-playbook-5d0l</link>
      <guid>https://dev.to/mtrnm/kubernetes-imagepullbackoff-crashloopbackoff-oomkilled-the-3-command-troubleshooting-playbook-5d0l</guid>
      <description>&lt;h1&gt;
  
  
  Kubernetes ImagePullBackOff, CrashLoopBackOff, OOMKilled: The 3-Command Troubleshooting Playbook
&lt;/h1&gt;

&lt;p&gt;Most Kubernetes ImagePullBackOff / CrashLoopBackOff troubleshooting is not deep. The statuses read like spaceship error codes, but nearly all of them fall out of three commands and a short list of causes. This is the playbook I run on autopilot, plus the one k3d trap that cost me a morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The golden set
&lt;/h2&gt;

&lt;p&gt;Whatever the status, start here. Same &lt;code&gt;describe&lt;/code&gt; / &lt;code&gt;logs&lt;/code&gt; / &lt;code&gt;exec&lt;/code&gt; loop &lt;a href="https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/" rel="noopener noreferrer"&gt;the Kubernetes 'Debug Running Pods' guide&lt;/a&gt; recommends.&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. State, Reason, and the Events block at the bottom — ALWAYS first&lt;/span&gt;
kubectl describe pod &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp

&lt;span class="c"&gt;# 2. App logs, and the CRASHED container's logs via --previous&lt;/span&gt;
kubectl logs &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp
kubectl logs &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;--previous&lt;/span&gt;

&lt;span class="c"&gt;# 3. Namespace timeline&lt;/span&gt;
kubectl get events &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.lastTimestamp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;describe&lt;/code&gt; hands you three fields: &lt;code&gt;State&lt;/code&gt;, &lt;code&gt;Reason&lt;/code&gt;, and &lt;code&gt;Events&lt;/code&gt;. That's the loop. The rest is pattern-matching. There's a solid field guide to these same statuses at &lt;a href="https://dorokhovich.com/blog/local-k8s-troubleshooting?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-troubleshooting" rel="noopener noreferrer"&gt;this troubleshooting writeup&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  ImagePullBackOff — and the k3d containerd trap
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ErrImagePull&lt;/code&gt; first, then &lt;code&gt;ImagePullBackOff&lt;/code&gt; after backoff. Pod stuck &lt;code&gt;Waiting&lt;/code&gt;. Events line usually says it outright: &lt;code&gt;not found&lt;/code&gt; / &lt;code&gt;unauthorized&lt;/code&gt; / &lt;code&gt;no such host&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;The boring causes are enumerated in &lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;the Kubernetes images docs&lt;/a&gt;: typo in name or tag, private registry with no &lt;code&gt;imagePullSecrets&lt;/code&gt;, Docker Hub rate limit (&lt;code&gt;toomanyrequests&lt;/code&gt;), bad registry address.&lt;/p&gt;

&lt;p&gt;The trap that isn't boring: k3d nodes run on &lt;strong&gt;containerd, isolated from your Docker daemon&lt;/strong&gt;. The image you just built with &lt;code&gt;docker build&lt;/code&gt; — visible right there in &lt;code&gt;docker images&lt;/code&gt; — the cluster cannot see. Docker has it; containerd doesn't. &lt;code&gt;ImagePullBackOff&lt;/code&gt;, even though "the image is obviously built."&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Two correct ways to get the image into the cluster, both in &lt;a href="https://k3d.io/v5.7.5/usage/importing_images/" rel="noopener noreferrer"&gt;the k3d image-import docs&lt;/a&gt;:&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;# Option 1: import into the nodes directly&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; myapp:dev &lt;span class="nb"&gt;.&lt;/span&gt;
k3d image import myapp:dev &lt;span class="nt"&gt;-c&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Option 2 (recommended): a local registry created with the cluster&lt;/span&gt;
k3d cluster create dev &lt;span class="nt"&gt;--registry-create&lt;/span&gt; k3d-registry.localhost:5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# then the FULL name matters: address + port + tag&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k3d-registry.localhost:5000/myapp:dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classic follow-up mistake is an incomplete image name — &lt;code&gt;myapp:dev&lt;/code&gt; instead of &lt;code&gt;k3d-registry.localhost:5000/myapp:dev&lt;/code&gt;. The cluster looks in the wrong place and you're back in backoff. If you use Tilt, &lt;code&gt;docker_build&lt;/code&gt; handles delivery, but the Tiltfile image name must match the manifest exactly or you get the same error.&lt;/p&gt;

&lt;h2&gt;
  
  
  CrashLoopBackOff — the status that hides its own cause
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;Container starts, crashes, kubelet restarts it, it crashes again — with exponential backoff, roughly 10s → 20s → 40s, capped at 5 minutes. The status itself tells you nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;The command I wish I'd known on day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;--previous&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--previous&lt;/code&gt; you get the freshly-started container, which usually hasn't written anything. &lt;code&gt;--previous&lt;/code&gt; pulls the stack trace from the instance that actually died. Also read &lt;code&gt;Last State: Terminated&lt;/code&gt; and its &lt;code&gt;Exit Code&lt;/code&gt; in &lt;code&gt;describe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Causes I check in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Startup bug&lt;/strong&gt; — exception, non-zero exit. For a FastAPI service, missing &lt;code&gt;DB_HOST&lt;/code&gt; / &lt;code&gt;DB_PASSWORD&lt;/code&gt; crashes it on connect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing env/config&lt;/strong&gt; — a ConfigMap/Secret isn't mounted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency not up&lt;/strong&gt; — Postgres hasn't started and the app doesn't wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OOM&lt;/strong&gt; — out of memory (below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Liveness too strict&lt;/strong&gt; — kills the app before it warms up. Fix with &lt;code&gt;initialDelaySeconds&lt;/code&gt; or a &lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;startup probe&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit code 0&lt;/strong&gt; — for a long-running service, "exited successfully" is still a crash loop. Usually a wrong entrypoint that runs and exits instead of starting uvicorn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the container dies instantly and logs are empty, keep it alive and climb inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# temporary, in the Deployment&lt;/span&gt;
&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;infinity"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;--&lt;/span&gt; sh
&lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;DB_
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="c"&gt;# run by hand, read the real error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pending — the scheduler couldn't place it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;Pod never leaves &lt;code&gt;Pending&lt;/code&gt;. &lt;code&gt;describe&lt;/code&gt; → Events → &lt;code&gt;FailedScheduling&lt;/code&gt;, usually naming the cause: &lt;code&gt;Insufficient cpu&lt;/code&gt; / &lt;code&gt;Insufficient memory&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;On a small local cluster this is the most common one. I once set &lt;code&gt;requests: 4&lt;/code&gt; CPUs on a 2-CPU k3d VM and the pod simply never scheduled. Other causes: nodeSelector/affinity mismatch, taints without tolerations, an unbound PVC, a taken &lt;code&gt;hostPort&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Lower &lt;code&gt;requests&lt;/code&gt;, fix the selector/PVC, or grow the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;k3d node create extra &lt;span class="nt"&gt;--cluster&lt;/span&gt; dev &lt;span class="nt"&gt;--role&lt;/span&gt; agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a local FastAPI service, &lt;code&gt;100m&lt;/code&gt; CPU / &lt;code&gt;128Mi&lt;/code&gt; memory is fine. Don't paste production numbers into a laptop cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  OOMKilled — the exit-137 signature
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;Process blew past its memory limit and the kernel &lt;code&gt;SIGKILL&lt;/code&gt;'d it. Recognize it by exit code &lt;strong&gt;137&lt;/strong&gt; (128 + 9, SIGKILL) and &lt;code&gt;Reason: OOMKilled&lt;/code&gt; in &lt;code&gt;describe&lt;/code&gt;. That Reason field distinguishes a memory kill from other SIGKILLs, and OOM is often the hidden cause behind a CrashLoopBackOff.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;Two flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container-level&lt;/strong&gt; — exceeded &lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/assign-memory-resource/" rel="noopener noreferrer"&gt;&lt;code&gt;resources.limits.memory&lt;/code&gt;&lt;/a&gt;. Raise the limit or fix a leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node-level&lt;/strong&gt; (sneaky on k3d) — the whole Docker VM ran out of memory, so pods get OOM-killed even while each app is within its own limit. The fix isn't Kubernetes limits; it's giving the Docker VM more memory, or not overcommitting — the sum of all pods' limits shouldn't exceed the VM's memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Service that's green but silent
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;Pods &lt;code&gt;Running&lt;/code&gt;, everything looks fine, requests never reach the app. Almost always one of three breaks in &lt;code&gt;Service → endpoints → Pod&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Check endpoints first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get endpointslices &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;-l&lt;/span&gt; kubernetes.io/service-name&lt;span class="o"&gt;=&lt;/span&gt;myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty means nothing is behind the Service. Three causes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Selector mismatch&lt;/strong&gt; — labels are case-sensitive, &lt;code&gt;app: Web&lt;/code&gt; ≠ &lt;code&gt;app: web&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port mismatch&lt;/strong&gt; — &lt;code&gt;targetPort&lt;/code&gt; must equal the real &lt;code&gt;containerPort&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readiness not passing&lt;/strong&gt; — pod shows &lt;code&gt;0/1 Ready&lt;/code&gt;, Kubernetes pulls it from endpoints so no traffic flows, but the container doesn't restart and still reads &lt;code&gt;Running&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Localize by hitting the pod directly, bypassing the Service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp 8080:8080
curl http://localhost:8080/healthz   &lt;span class="c"&gt;# works? then it's selector/endpoints/readiness, not the app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Before / after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Old reflex&lt;/th&gt;
&lt;th&gt;The move&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ImagePullBackOff&lt;/td&gt;
&lt;td&gt;re-&lt;code&gt;docker build&lt;/code&gt;, panic&lt;/td&gt;
&lt;td&gt;check k3d containerd/registry gotcha; full image name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CrashLoopBackOff&lt;/td&gt;
&lt;td&gt;stare at empty logs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;logs --previous&lt;/code&gt; + exit code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pending&lt;/td&gt;
&lt;td&gt;assume cluster broken&lt;/td&gt;
&lt;td&gt;read &lt;code&gt;FailedScheduling&lt;/code&gt;; shrink requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OOMKilled&lt;/td&gt;
&lt;td&gt;confused restarts&lt;/td&gt;
&lt;td&gt;spot exit 137 + &lt;code&gt;Reason: OOMKilled&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service silent&lt;/td&gt;
&lt;td&gt;blame the app&lt;/td&gt;
&lt;td&gt;check endpoints/selector/readiness&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The whole thing is one algorithm: &lt;code&gt;get pods&lt;/code&gt; shows &lt;em&gt;what&lt;/em&gt; → &lt;code&gt;describe&lt;/code&gt; explains &lt;em&gt;why&lt;/em&gt; in &lt;code&gt;Reason&lt;/code&gt;/&lt;code&gt;Events&lt;/code&gt; → &lt;code&gt;logs --previous&lt;/code&gt; gives the crash detail. Ninety percent of cases are on the list above.&lt;/li&gt;
&lt;li&gt;Watch disk creep. Kubelet image GC is lazy — &lt;a href="https://kubernetes.io/docs/concepts/architecture/garbage-collection/" rel="noopener noreferrer"&gt;it won't clean until the node's disk crosses its high-threshold, 85% by default&lt;/a&gt; — so images pile up on purpose. &lt;code&gt;docker system df&lt;/code&gt; to see it, prune carefully; &lt;code&gt;docker system prune -a&lt;/code&gt; can delete images the cluster still needs.&lt;/li&gt;
&lt;li&gt;Keep the golden set in muscle memory. It's faster than any search bar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Stop treating a red status as a mystery. Every one of them resolves to a &lt;code&gt;Reason&lt;/code&gt; field and a short cause list. The dread was never warranted — the statuses just have unfriendly names.&lt;/p&gt;

&lt;p&gt;Bottom line: memorize &lt;code&gt;describe&lt;/code&gt; + &lt;code&gt;logs --previous&lt;/code&gt; + &lt;code&gt;get events&lt;/code&gt;, learn the five common statuses, and remember that in k3d "the image is built" and "the cluster can see it" are two different facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/" rel="noopener noreferrer"&gt;Kubernetes docs — Debug Running Pods (&lt;code&gt;describe&lt;/code&gt; / &lt;code&gt;logs&lt;/code&gt; / &lt;code&gt;exec&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;Kubernetes docs — Images (image pull, &lt;code&gt;imagePullPolicy&lt;/code&gt;, pull errors)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://k3d.io/v5.7.5/usage/importing_images/" rel="noopener noreferrer"&gt;k3d docs — Importing images (&lt;code&gt;k3d image import&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/assign-memory-resource/" rel="noopener noreferrer"&gt;Kubernetes docs — Assign Memory Resources (limits, OOMKilled)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;Kubernetes docs — Liveness, Readiness and Startup Probes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/architecture/garbage-collection/" rel="noopener noreferrer"&gt;Kubernetes docs — Node image garbage collection (disk thresholds)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-troubleshooting?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-troubleshooting" rel="noopener noreferrer"&gt;Full local-k8s troubleshooting playbook&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>docker-compose vs Kubernetes: The dev/prod Parity Gap That Ate Our First Rollout</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:26:02 +0000</pubDate>
      <link>https://dev.to/mtrnm/docker-compose-vs-kubernetes-the-devprod-parity-gap-that-ate-our-first-rollout-4kko</link>
      <guid>https://dev.to/mtrnm/docker-compose-vs-kubernetes-the-devprod-parity-gap-that-ate-our-first-rollout-4kko</guid>
      <description>&lt;h1&gt;
  
  
  docker-compose vs Kubernetes: The dev/prod Parity Gap That Ate Our First Rollout
&lt;/h1&gt;

&lt;p&gt;The service passed every local test in &lt;code&gt;docker-compose&lt;/code&gt;. The first Kubernetes rollout fell over inside an hour on things Compose had never modeled: readiness, Secrets, startup order, service discovery. This is the docker-compose vs kubernetes dev/prod parity gap, and it cost us an outage before I admitted it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;First prod rollout. Pod &lt;code&gt;Running&lt;/code&gt;, but the Service sent it zero traffic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api
&lt;span class="go"&gt;NAME                   READY   STATUS    RESTARTS   AGE
api-6c9f8b7d4-2xk9p    0/1     Running   3          2m

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl describe pod api-6c9f8b7d4-2xk9p | &lt;span class="nb"&gt;tail&lt;/span&gt;
&lt;span class="go"&gt;  Warning  Unhealthy  readiness probe failed: connection refused
&lt;/span&gt;&lt;span class="c"&gt;  ...
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl logs api-6c9f8b7d4-2xk9p &lt;span class="nt"&gt;--previous&lt;/span&gt;
&lt;span class="go"&gt;  sqlalchemy.exc.OperationalError: could not connect to server: Connection refused
  Is the server running on host "postgres" (10.43.x.x) and accepting connections?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate failures, none of which any green Compose test had touched:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No readiness probe existed, so I'd never configured one — Compose has no such concept.&lt;/li&gt;
&lt;li&gt;A plaintext env var in Compose was supposed to be a Secret in k8s.&lt;/li&gt;
&lt;li&gt;The app crashed reaching the DB before the DB was up. Compose's &lt;code&gt;depends_on&lt;/code&gt; had papered over that for years.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;I'd assumed a Compose file and a set of k8s manifests were the same idea at different fidelity. They aren't. The &lt;a href="https://12factor.net/dev-prod-parity" rel="noopener noreferrer"&gt;Twelve-Factor App's tenth factor, dev/prod parity&lt;/a&gt;, names the trap: keep the gaps between dev and prod small — especially the &lt;strong&gt;tools&lt;/strong&gt; gap, where "tiny incompatibilities crop up, causing code that worked and passed tests in development to fail in production."&lt;/p&gt;

&lt;p&gt;Two different problems live under that one heading, and I'd conflated them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backing-service parity.&lt;/strong&gt; The canonical sin is SQLite locally, Postgres in prod. Sooner or later a behavioral difference bites at the worst moment. Same type, same version, both sides. No substitutions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manifest-level bugs.&lt;/strong&gt; A whole class of failure lives only at the Kubernetes object layer: misconfigured probes, thin RBAC, service discovery by in-cluster DNS, missing resource limits. Compose has none of these concepts, so it can surface none of these bugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second bucket is the one that got me. A single Compose service is not one Kubernetes object — it's several, each carrying its own slice of production behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;Deployment&lt;/strong&gt; — how the pods run&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;Service&lt;/strong&gt; — how they're reached&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;ConfigMap&lt;/strong&gt; + &lt;strong&gt;Secret&lt;/strong&gt; — config and secrets&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;Ingress&lt;/strong&gt; — external access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is a place to misconfigure something, and a place Compose never made me think about. There's a solid breakdown of what "production-like local" actually means at &lt;a href="https://dorokhovich.com/blog/local-k8s-production-like-environments?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-production-like-environments" rel="noopener noreferrer"&gt;this writeup on production-like local environments&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that didn't work: Kompose
&lt;/h2&gt;

&lt;p&gt;First instinct was to cheat and auto-convert. &lt;a href="https://github.com/kubernetes/kompose" rel="noopener noreferrer"&gt;Kompose&lt;/a&gt; is a real project under the Kubernetes org, so I ran it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# a starting point, NOT a prod manifest&lt;/span&gt;
kompose convert &lt;span class="nt"&gt;-f&lt;/span&gt; compose.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kompose's own authors are honest: "our conversions are not always 1-1... but will get you 99% of the way there." Production lives in that last 1%. What the conversion silently drops or mangles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;depends_on&lt;/code&gt; is ignored.&lt;/strong&gt; Kubernetes has no "start B after A." You use init containers or, better, app-level retries — reconnect to Postgres until it answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;build:&lt;/code&gt; doesn't build.&lt;/strong&gt; Kubernetes can't build from source. The image must already be pushed to a registry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;network_mode: host&lt;/code&gt; and custom networks&lt;/strong&gt; map poorly or not at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind mounts vanish.&lt;/strong&gt; You're expected to use a ConfigMap/Secret.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The kicker: default Kompose output ships &lt;strong&gt;without &lt;code&gt;requests&lt;/code&gt;/&lt;code&gt;limits&lt;/code&gt;, without probes, with env vars in plaintext instead of Secrets.&lt;/strong&gt; The automation strips out exactly the production-like properties you were trying to gain. A draft, not a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that worked: a production-like k3d cluster
&lt;/h2&gt;

&lt;p&gt;I switched to running the service in a real local cluster with &lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;k3d&lt;/a&gt; (k3s in Docker). The discipline that mattered was deciding, on purpose, what to reproduce and what to simplify.&lt;/p&gt;

&lt;p&gt;Reproduce, no exceptions:&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;# pin the SAME minor as prod. Docker tags can't use '+',&lt;/span&gt;
&lt;span class="c"&gt;# so it's 'v1.31.5-k3s1', not 'v1.30.2+k3s1'&lt;/span&gt;
k3d cluster create dev &lt;span class="nt"&gt;--image&lt;/span&gt; rancher/k3s:v1.31.5-k3s1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Same backing services by type and version — Postgres, never SQLite.&lt;/li&gt;
&lt;li&gt;Real &lt;code&gt;requests&lt;/code&gt;/&lt;code&gt;limits&lt;/code&gt;, so scheduler and eviction behavior is realistic.&lt;/li&gt;
&lt;li&gt;The exact probe pair that would've saved the first rollout — &lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;liveness and readiness&lt;/a&gt;: &lt;code&gt;/healthz&lt;/code&gt; liveness (process is up), &lt;code&gt;/ready&lt;/code&gt; readiness (checks the Postgres connection; no traffic while the DB is unreachable, but no restart either).&lt;/li&gt;
&lt;li&gt;ConfigMap + &lt;a href="https://kubernetes.io/docs/concepts/configuration/secret/" rel="noopener noreferrer"&gt;Secret&lt;/a&gt; instead of hardcoding; Ingress + in-cluster DNS so routing matches prod.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same real manifests, deployed the same declarative way as prod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; k8s/overlays/dev
kubectl rollout status deploy/api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deliberately simplify, and write down why: prod scale and topology, managed cloud services (Postgres in a container instead of RDS), and load testing (doesn't belong on the box running the app it tests).&lt;/p&gt;

&lt;h3&gt;
  
  
  Proving the three bugs are dead, locally
&lt;/h3&gt;

&lt;p&gt;The point of parity is that the failures now reproduce on the laptop. I ran each one on purpose before trusting the rollout:&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. startup order: kill the DB, restart the app, watch it retry not crashloop&lt;/span&gt;
kubectl delete pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres
kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/api        &lt;span class="c"&gt;# "waiting for postgres... retry 3" then ready&lt;/span&gt;

&lt;span class="c"&gt;# 2. readiness gates traffic: app is up but DB down -&amp;gt; 0/1, no endpoint&lt;/span&gt;
kubectl get endpointslices &lt;span class="nt"&gt;-l&lt;/span&gt; kubernetes.io/service-name&lt;span class="o"&gt;=&lt;/span&gt;api

&lt;span class="c"&gt;# 3. secret is wired, not baked into the image&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/api &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;printenv &lt;/span&gt;DATABASE_URL   &lt;span class="c"&gt;# from Secret, not Dockerfile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three passed at my desk. The next prod rollout was uneventful — the first one in that project that ever had been.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;docker-compose only&lt;/th&gt;
&lt;th&gt;Production-like k3d&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backing service&lt;/td&gt;
&lt;td&gt;often SQLite/stand-in&lt;/td&gt;
&lt;td&gt;same Postgres + version as prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;K8s version&lt;/td&gt;
&lt;td&gt;not modeled&lt;/td&gt;
&lt;td&gt;pinned to match prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;liveness + readiness, real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;plaintext env&lt;/td&gt;
&lt;td&gt;ConfigMap + Secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service discovery&lt;/td&gt;
&lt;td&gt;Compose networking&lt;/td&gt;
&lt;td&gt;real CoreDNS / Ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;depends_on&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hides startup-order bugs&lt;/td&gt;
&lt;td&gt;forces real retry logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First prod rollout&lt;/td&gt;
&lt;td&gt;collapsed on unmodeled objects&lt;/td&gt;
&lt;td&gt;boring, matched local&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Works in Compose" validates the app, not the manifests.&lt;/strong&gt; Manifest bugs only appear after a real rollout — so test in k8s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production-like is not "everything like prod."&lt;/strong&gt; It's an environment where you know exactly what matches, what doesn't, and why. The list of documented simplifications is the most underrated line item on the whole checklist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don't need the whole prod zoo.&lt;/strong&gt; Bring up only the namespace and services you're touching this session. Parity isn't recreating everything at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't try to copy prod exactly.&lt;/strong&gt; Copying prod onto a laptop is impossible and pointless. The goal is narrower: shrink the gap between how the service behaves at your desk and how it behaves in prod, so packaging and deployment bugs get caught before the rollout.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Keep Compose for the earliest, fastest hacking loop — it still earns its place there. Just never again mistake a green Compose run for validation of a Kubernetes deploy. The moment a service is bound for k8s, it gets tested in a local cluster on pinned versions and the same manifests as prod. My rollouts have since gotten boring, which after that first outage is the highest praise I have.&lt;/p&gt;

&lt;p&gt;Bottom line: a Compose file is structurally not equivalent to k8s manifests — pin the same versions and run the same manifests in a local cluster, or the manifest-layer bugs wait for prod to find them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://12factor.net/dev-prod-parity" rel="noopener noreferrer"&gt;The Twelve-Factor App — X. Dev/prod parity&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kubernetes/kompose" rel="noopener noreferrer"&gt;Kompose — convert Compose to Kubernetes (kubernetes/kompose)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;k3d — run CNCF's k3s in Docker for local clusters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;Kubernetes docs — Configure Liveness, Readiness and Startup Probes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/configuration/secret/" rel="noopener noreferrer"&gt;Kubernetes docs — Secrets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-production-like-environments?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-production-like-environments" rel="noopener noreferrer"&gt;Walkthrough on production-like local environments&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>The Kubernetes `:latest` Tag Problem: How a Mutable Tag Rolled Our Prod Back for 40 Minutes</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:15:50 +0000</pubDate>
      <link>https://dev.to/mtrnm/the-kubernetes-latest-tag-problem-how-a-mutable-tag-rolled-our-prod-back-for-40-minutes-1nlk</link>
      <guid>https://dev.to/mtrnm/the-kubernetes-latest-tag-problem-how-a-mutable-tag-rolled-our-prod-back-for-40-minutes-1nlk</guid>
      <description>&lt;h1&gt;
  
  
  The Kubernetes &lt;code&gt;:latest&lt;/code&gt; Tag Problem: How a Mutable Tag Rolled Our Prod Back for 40 Minutes
&lt;/h1&gt;

&lt;p&gt;Nobody deployed. Nobody ran &lt;code&gt;kubectl apply&lt;/code&gt;. At 02:00 the &lt;code&gt;orders&lt;/code&gt; API started throwing errors we'd fixed three weeks earlier, and it stayed broken for 40 minutes. The kubernetes latest tag problem is not theoretical. This is the incident that made me delete &lt;code&gt;:latest&lt;/code&gt; from every manifest we own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;On-call paged. Errors that were fixed weeks ago, live again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;2025-... POST /orders 500  InvalidStateError: order already settled
2025-... POST /orders 500
2025-... POST /orders 500
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy history: empty. No release. No &lt;code&gt;apply&lt;/code&gt;. No human near the cluster. Running code had changed anyway. First reflex was to blame a rogue actor with cluster creds. &lt;code&gt;kubectl get events&lt;/code&gt; and the audit log both said no — nobody had written to the API server in hours. The image had moved without a single Kubernetes write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;The Deployment referenced a mutable tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timeline, reconstructed from the kubelet events and registry push log:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Someone pushed a mid-refactor build and tagged it &lt;code&gt;latest&lt;/code&gt;. Never promoted it.&lt;/li&gt;
&lt;li&gt;Hours later a pod OOM'd and restarted.&lt;/li&gt;
&lt;li&gt;On restart the kubelet pulled &lt;code&gt;latest&lt;/code&gt; again — now pointing at the broken build.&lt;/li&gt;
&lt;li&gt;Pod came up on code nobody chose to deploy. Prod changed its own version while we slept.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three separate properties of &lt;code&gt;latest&lt;/code&gt; conspired here, and the &lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;Kubernetes image docs&lt;/a&gt; warn about all of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;latest&lt;/code&gt; is mutable.&lt;/strong&gt; It points at a different digest tomorrow. You cannot answer "what is running in prod?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-running &lt;code&gt;apply&lt;/code&gt; is a no-op.&lt;/strong&gt; Kubernetes rolls out only when the pod template changes. The literal string &lt;code&gt;image: orders:latest&lt;/code&gt; never changes, so the cluster sees nothing to do even when the registry digest is completely different.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A restart re-pulls.&lt;/strong&gt; &lt;code&gt;imagePullPolicy&lt;/code&gt; defaults to &lt;code&gt;Always&lt;/code&gt; for &lt;code&gt;latest&lt;/code&gt; (and &lt;code&gt;IfNotPresent&lt;/code&gt; for a pinned tag). So &lt;code&gt;latest&lt;/code&gt; maximizes the odds of a surprise pull on every restart. The inverse trap is just as ugly: a pinned tag with &lt;code&gt;IfNotPresent&lt;/code&gt; can stick on a stale local layer and never pull the fix you just pushed. Both are the same disease — the running image and the name you typed have drifted apart.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a solid breakdown of the full local-to-CI path that this class of bug forced on us at &lt;a href="https://dorokhovich.com/blog/local-k8s-preparing-for-deployment-and-ci?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-preparing-for-deployment-and-ci" rel="noopener noreferrer"&gt;this writeup on preparing a local setup for deployment and CI&lt;/a&gt;. I'll stick to the fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Immutable tags, generated in CI
&lt;/h3&gt;

&lt;p&gt;Rule: every image is tagged by something that resolves to exactly one build. Short SHA, semver, or a digest pin for maximum determinism.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/orders@sha256:45b23dee...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't hand-write tags. &lt;a href="https://github.com/docker/metadata-action" rel="noopener noreferrer"&gt;&lt;code&gt;docker/metadata-action&lt;/code&gt;&lt;/a&gt; emits them from the git context and writes OCI labels like &lt;code&gt;org.opencontainers.image.revision&lt;/code&gt;. "What's in prod?" collapses from an investigation to one label read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect ghcr.io/acme/orders:sha-abc1234 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{ index .Config.Labels "org.opencontainers.image.revision" }}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. One base, kill the copy-pasted &lt;code&gt;k8s-prod/&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The other sin: a &lt;code&gt;k8s/&lt;/code&gt; folder copied to &lt;code&gt;k8s-prod/&lt;/code&gt; months earlier. Two copies drift. Ours had, and prod's memory limit no longer matched what we'd tested. Configuration drift, cured by a single base with only the diffs layered on top.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/" rel="noopener noreferrer"&gt;Kustomize&lt;/a&gt; — built into &lt;code&gt;kubectl&lt;/code&gt;, plain YAML, no templating engine to reason about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# overlays/prod/kustomization.yaml&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;../../base&lt;/span&gt;
&lt;span class="na"&gt;namePrefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod-&lt;/span&gt;
&lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
    &lt;span class="na"&gt;newName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/orders&lt;/span&gt;
    &lt;span class="na"&gt;newTag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sha-abc1234&lt;/span&gt;    &lt;span class="c1"&gt;# swap the image without touching the Deployment&lt;/span&gt;
&lt;span class="na"&gt;patches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;replicas-patch.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# render and eyeball it before it touches the cluster&lt;/span&gt;
kustomize build overlays/prod | kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;images&lt;/code&gt; field is the quiet win: CI rewrites the tag without editing the Deployment YAML. Helm with a per-env &lt;code&gt;values-prod.yaml&lt;/code&gt; lands the same place — just mind &lt;code&gt;-f&lt;/code&gt; ordering, last file wins, and swapping the order silently changes your prod replica count.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A pipeline that physically cannot ship &lt;code&gt;:latest&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Push-based and deliberately dumb: commit → build → push → update manifest to the immutable tag → apply. GitHub Actions skeleton, using Docker's &lt;a href="https://github.com/docker/build-push-action" rel="noopener noreferrer"&gt;&lt;code&gt;build-push-action&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-deploy&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/metadata-action@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/orders&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;type=sha&lt;/span&gt;
            &lt;span class="s"&gt;type=semver,pattern={{version}}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.tags }}&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.labels }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "${{ secrets.KUBECONFIG }}" &amp;gt; kubeconfig&lt;/span&gt;
          &lt;span class="s"&gt;export KUBECONFIG=kubeconfig&lt;/span&gt;
          &lt;span class="s"&gt;kubectl set image deployment/orders \&lt;/span&gt;
            &lt;span class="s"&gt;orders=ghcr.io/acme/orders:sha-${GITHUB_SHA::7}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deploy step sets an immutable tag, never &lt;code&gt;latest&lt;/code&gt;. Now the pod template genuinely differs, the rollout is honest, and a restart can't re-pull a mystery image.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducing the outage on k3d first
&lt;/h3&gt;

&lt;p&gt;I didn't ship any of this blind. I reproduced the exact failure on a laptop k3d cluster, then confirmed the fix killed it. The whole loop is a few minutes and costs nothing.&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 latest-repro
&lt;span class="c"&gt;# push two builds to the same mutable tag&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; k3d-registry:5000/orders:latest ./good &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker push k3d-registry:5000/orders:latest
kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; overlays/local           &lt;span class="c"&gt;# Deployment references :latest&lt;/span&gt;
kubectl rollout status deploy/orders

&lt;span class="c"&gt;# now poison latest and force a restart, no apply involved&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; k3d-registry:5000/orders:latest ./broken &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker push k3d-registry:5000/orders:latest
kubectl delete pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;orders          &lt;span class="c"&gt;# simulate the 02:00 crash&lt;/span&gt;
kubectl get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;orders &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].spec.containers[0].image}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod came back on the broken image with an empty deploy history — the outage, on demand. Re-run the same script against the SHA-pinned overlay and the delete/restart brings the &lt;em&gt;same&lt;/em&gt; image back every time. That's the whole proof. Once it held, I trusted it in prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Image tags&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;immutable &lt;code&gt;sha-…&lt;/code&gt; / digest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"What's in prod?"&lt;/td&gt;
&lt;td&gt;an investigation&lt;/td&gt;
&lt;td&gt;one label read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Environments&lt;/td&gt;
&lt;td&gt;copy-pasted &lt;code&gt;k8s-prod/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one base + overlays&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drift&lt;/td&gt;
&lt;td&gt;silent, unbounded&lt;/td&gt;
&lt;td&gt;the diff is the only diff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restart&lt;/td&gt;
&lt;td&gt;may pull broken &lt;code&gt;latest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;pinned, deterministic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollout on redeploy&lt;/td&gt;
&lt;td&gt;sometimes a no-op&lt;/td&gt;
&lt;td&gt;always honest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Grep CI and manifests for &lt;code&gt;:latest&lt;/code&gt; in a pre-merge check. No merge if it hits.&lt;/li&gt;
&lt;li&gt;Most local Kubernetes work carried straight to prod: same Dockerfile, same Kustomize base, same probes. Prod parity is the payoff. Prod only &lt;em&gt;adds&lt;/em&gt; — registry, Secrets, TLS, HPA, PodDisruptionBudget.&lt;/li&gt;
&lt;li&gt;Some things must never cross the line: &lt;code&gt;--reload&lt;/code&gt;, file-sync dev accelerators, exposed debug ports. The prod overlay is the mechanism that keeps them out.&lt;/li&gt;
&lt;li&gt;Pin a digest, not just a tag, for anything you truly can't afford to move — &lt;code&gt;@sha256:...&lt;/code&gt; is the only reference in Kubernetes that a registry cannot repoint under you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Move off the push pipeline sooner. It has three built-in weaknesses I already know will bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CI holds the kubeconfig.&lt;/strong&gt; CI access equals prod access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift is invisible.&lt;/strong&gt; Someone runs &lt;code&gt;kubectl edit&lt;/code&gt; by hand and CI never notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No automatic rollback.&lt;/strong&gt; Ship broken, revert by hand, at 02:00.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those three are exactly what GitOps removes. Per the &lt;a href="https://opengitops.dev/" rel="noopener noreferrer"&gt;CNCF OpenGitOps principles&lt;/a&gt;, Git is the source of truth and an in-cluster agent (Argo CD or Flux) pulls and reconciles drift. CI's job ends at pushing the image and committing the tag. That's the next rung: CI builds an immutable image → a bot bumps the tag in Git → the agent applies it.&lt;/p&gt;

&lt;p&gt;Bottom line: the outage wasn't a Kubernetes flaw, it was a mutable tag plus a copy-pasted folder — pin an immutable SHA and collapse onto one base before your own 02:00 page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;Kubernetes docs — Images (avoid &lt;code&gt;:latest&lt;/code&gt;; imagePullPolicy defaults)&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 — Declarative management with Kustomize (bases &amp;amp; overlays)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/docker/metadata-action" rel="noopener noreferrer"&gt;docker/metadata-action — immutable image tags &amp;amp; OCI labels&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/docker/build-push-action" rel="noopener noreferrer"&gt;docker/build-push-action — build and push images in CI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://opengitops.dev/" rel="noopener noreferrer"&gt;CNCF OpenGitOps — the four GitOps principles&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-preparing-for-deployment-and-ci?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-preparing-for-deployment-and-ci" rel="noopener noreferrer"&gt;Local-to-production deployment &amp;amp; CI walkthrough&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>k3d Ingress Localhost Setup: Fixing "curl: could not resolve host" and the Dead Ingress</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:50:44 +0000</pubDate>
      <link>https://dev.to/mtrnm/k3d-ingress-localhost-setup-fixing-curl-could-not-resolve-host-and-the-dead-ingress-23f0</link>
      <guid>https://dev.to/mtrnm/k3d-ingress-localhost-setup-fixing-curl-could-not-resolve-host-and-the-dead-ingress-23f0</guid>
      <description>&lt;h1&gt;
  
  
  k3d Ingress Localhost Setup: Fixing "curl: could not resolve host" and the Dead Ingress
&lt;/h1&gt;

&lt;p&gt;Worked in Chrome. Died in curl. Safari showed a blank page. The Ingress existed and routed to a void. I burned two days on a k3d ingress localhost setup, and not one of the three failures was a bug. Every one was a documented layer I didn't understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;Moving a checkout service off &lt;code&gt;docker-compose&lt;/code&gt; into local k3d, because staging kept surfacing bugs the laptops never saw. FastAPI on 8080, Postgres behind it. Pods came up green.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl http://localhost:8080
&lt;span class="go"&gt;curl: (7) Failed to connect to localhost port 8080: Connection refused
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected — the cluster has its own network. What I didn't grasp was the three layers between my laptop and a pod. I faceplanted on all three. There's a solid breakdown of the whole path in &lt;a href="https://dorokhovich.com/blog/local-k8s-networking-and-ingress?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-networking-and-ingress" rel="noopener noreferrer"&gt;this local networking and Ingress writeup&lt;/a&gt;. Here's the compressed field version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 1: the Ingress existed and did nothing
&lt;/h2&gt;

&lt;p&gt;Reasonable manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout.localhost&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl apply&lt;/code&gt;, no errors. &lt;code&gt;kubectl get ingress&lt;/code&gt; showed it. No route, no response, no error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;The line from the &lt;a href="https://kubernetes.io/docs/concepts/services-networking/ingress/" rel="noopener noreferrer"&gt;Ingress docs&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You must have an Ingress controller to satisfy an Ingress. Only creating an Ingress resource has no effect.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An Ingress manifest is a declaration of rules. A controller has to be running to read them and accept traffic. I assumed "Ingress" was something Kubernetes did. It's something a controller does.&lt;/p&gt;

&lt;p&gt;k3d is built on k3s, and k3s ships &lt;strong&gt;Traefik out of the box&lt;/strong&gt; plus a ServiceLB so &lt;code&gt;LoadBalancer&lt;/code&gt; services don't hang &lt;code&gt;pending&lt;/code&gt; — the &lt;a href="https://k3d.io/v5.7.5/usage/k3s/" rel="noopener noreferrer"&gt;k3d bundled-k3s docs&lt;/a&gt; cover both. So the controller was there. My real problem was one layer down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 2: no route from the host
&lt;/h2&gt;

&lt;p&gt;Traefik ran inside the cluster on 80/443. Nothing on my host forwarded to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;k3d needs a port mapping to its loadbalancer node, set at creation:&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 &lt;span class="nt"&gt;--api-port&lt;/span&gt; 6550 &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"8081:80@loadbalancer"&lt;/span&gt; &lt;span class="nt"&gt;--agents&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-p "8081:80@loadbalancer"&lt;/code&gt; maps host &lt;code&gt;8081&lt;/code&gt; to Traefik's port &lt;code&gt;80&lt;/code&gt; — the &lt;a href="https://k3d.io/v5.7.5/usage/exposing_services/" rel="noopener noreferrer"&gt;k3d exposing-services guide&lt;/a&gt; documents the &lt;code&gt;HOST:CONTAINER@loadbalancer&lt;/code&gt; syntax.&lt;/p&gt;

&lt;p&gt;The gotcha that cost the most: &lt;strong&gt;you can only set this at cluster creation.&lt;/strong&gt; There is no &lt;code&gt;k3d cluster edit&lt;/code&gt; that adds a port to a running cluster. I hunted for one for an hour. Delete and recreate:&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 delete dev
k3d cluster create dev &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"8081:80@loadbalancer"&lt;/span&gt; &lt;span class="nt"&gt;--agents&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place the chain is the real prod routing shape, just &lt;code&gt;*.localhost&lt;/code&gt; instead of a domain and k3d instead of a cloud LB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -&amp;gt; host :8081 -&amp;gt; Traefik :80 -&amp;gt; rule host: checkout.localhost -&amp;gt; Service -&amp;gt; Pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How I confirmed each hop rather than guessing. Traefik has the mapping the moment the LB container exposes the port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Names}}\t{{.Ports}}'&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;serverlb
k3d-dev-serverlb   0.0.0.0:8081-&amp;gt;80/tcp, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the Service actually has endpoints — an empty endpoints list here means the selector doesn't match any pod, which is its own silent failure mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get endpoints checkout &lt;span class="nt"&gt;-n&lt;/span&gt; checkout
NAME       ENDPOINTS           AGE
checkout   10.42.0.14:8080     3m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty &lt;code&gt;ENDPOINTS&lt;/code&gt;? The Ingress and port mapping are irrelevant — fix the Service selector first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 3: Chrome worked, curl and Safari didn't
&lt;/h2&gt;

&lt;p&gt;After the recreate, &lt;code&gt;http://checkout.localhost:8081&lt;/code&gt; opened in Chrome. Four minutes of feeling clever, then QA on Safari said blank, and the integration test failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;curl: (6) Could not resolve host: checkout.localhost
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same URL, same cluster, opposite results by client. I blamed the cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;.localhost&lt;/code&gt; TLD is reserved by &lt;a href="https://datatracker.ietf.org/doc/html/rfc6761" rel="noopener noreferrer"&gt;RFC 6761&lt;/a&gt; for loopback, and resolvers are &lt;em&gt;supposed&lt;/em&gt; to return &lt;code&gt;127.0.0.1&lt;/code&gt; without hitting DNS. "Supposed to" does the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome and Firefox&lt;/strong&gt; resolve any &lt;code&gt;*.localhost&lt;/code&gt; to loopback themselves. Zero config. That's why it worked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari on macOS&lt;/strong&gt; does not — it defers to the OS resolver and finds nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;curl and every non-browser HTTP client&lt;/strong&gt; treat &lt;code&gt;*.localhost&lt;/code&gt; as an ordinary domain. No entry, &lt;code&gt;could not resolve host&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;One line, now in our onboarding README:&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;'127.0.0.1  checkout.localhost'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Windows: &lt;code&gt;C:\Windows\System32\drivers\etc\hosts&lt;/code&gt;, as admin.) After that, curl, Safari, and CI all behaved. Rule: &lt;strong&gt;Chrome/Firefox get &lt;code&gt;*.localhost&lt;/code&gt; free; curl, Safari, and any custom domain need an &lt;code&gt;/etc/hosts&lt;/code&gt; entry.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The DNS trap that bit a week later
&lt;/h2&gt;

&lt;p&gt;Saving you the trip. Inside the cluster you reach services by name via CoreDNS. Within one namespace, checkout hit Postgres as &lt;code&gt;postgres&lt;/code&gt;. I moved Postgres to a shared &lt;code&gt;data&lt;/code&gt; namespace and the short name broke:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nslookup postgres
&lt;span class="go"&gt;** server can't find postgres: NXDOMAIN
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From another namespace you need &lt;code&gt;postgres.data&lt;/code&gt; or the full FQDN &lt;code&gt;postgres.data.svc.cluster.local&lt;/code&gt; — the &lt;code&gt;&amp;lt;service&amp;gt;.&amp;lt;namespace&amp;gt;.svc.cluster.local&lt;/code&gt; form in the &lt;a href="https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/" rel="noopener noreferrer"&gt;DNS for Services and Pods spec&lt;/a&gt;. Test resolution from a throwaway pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; dnstest &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  nslookup checkout.checkout.svc.cluster.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb: hard-code the short name in your config and you've hard-coded "same namespace forever." I now use the two-part &lt;code&gt;&amp;lt;service&amp;gt;.&amp;lt;namespace&amp;gt;&lt;/code&gt; form everywhere, so moving a dependency across namespaces is a one-line env change, not a debugging session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runbook: prod-like local routing from scratch
&lt;/h2&gt;

&lt;p&gt;What I do now, every new cluster, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create with the mapping baked in: &lt;code&gt;k3d cluster create dev -p "8081:80@loadbalancer" --agents 2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Confirm the controller exists: &lt;code&gt;kubectl -n kube-system get pods | grep traefik&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Apply Deployment + Service, confirm endpoints are non-empty: &lt;code&gt;kubectl get endpoints &amp;lt;svc&amp;gt; -n &amp;lt;ns&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Apply the Ingress with &lt;code&gt;host: &amp;lt;app&amp;gt;.localhost&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add the hosts entry so curl and CI match Chrome: &lt;code&gt;echo '127.0.0.1  &amp;lt;app&amp;gt;.localhost' | sudo tee -a /etc/hosts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Smoke test with curl, not the browser: &lt;code&gt;curl -sS http://&amp;lt;app&amp;gt;.localhost:8081/healthz&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 6 with curl and not Chrome is deliberate — curl fails loudly on the DNS gap, Chrome hides it. Test with the strict client first.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I reach for what
&lt;/h2&gt;

&lt;p&gt;Port-forward, NodePort, and Ingress aren't competitors. Different tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ingress (Traefik + &lt;code&gt;*.localhost&lt;/code&gt;)&lt;/strong&gt; — default. Reproduces the prod path: host/path/TLS through a controller, human-friendly domains, load-balances across replicas. Configure once, forget it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl port-forward&lt;/code&gt;&lt;/strong&gt; — a debug tunnel to a single pod. Dies when the pod is recreated. It's a probe, not access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NodePort&lt;/strong&gt; — the lazy fallback on 30000-32767. Least prod-like. I basically never use it; k3d docs warn wide NodePort ranges can hang the system on iptables churn.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# everyday debug reflex — straight into a pod, bypassing the Service&lt;/span&gt;
kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; checkout deploy/checkout 8080:8080
curl http://localhost:8080/healthz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;p&gt;When local networking "flakes," check three things in order before touching the cluster:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is a controller actually running? A bare Ingress does nothing on its own.&lt;/li&gt;
&lt;li&gt;Was the &lt;code&gt;-p "HOST:80@loadbalancer"&lt;/code&gt; mapping set at creation? It's creation-time only — recreate if not.&lt;/li&gt;
&lt;li&gt;What client are you testing with? Chrome hides the DNS gap; curl and Safari expose it. Add the &lt;code&gt;/etc/hosts&lt;/code&gt; line.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Put the port mapping and the &lt;code&gt;/etc/hosts&lt;/code&gt; line in the cluster bootstrap script from day one, so nobody recreates a cluster five times to learn what a controller is. And test with curl first, not Chrome — Chrome's free &lt;code&gt;*.localhost&lt;/code&gt; resolution papers over a gap CI will hit anyway.&lt;/p&gt;

&lt;p&gt;Bottom line: none of these were bugs — controller, creation-time port mapping, and client-side &lt;code&gt;.localhost&lt;/code&gt; resolution are three separate layers, and knowing which one you're on turns two lost days into two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/services-networking/ingress/" rel="noopener noreferrer"&gt;Kubernetes docs — Ingress (a controller is required to satisfy an Ingress)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://k3d.io/v5.7.5/usage/exposing_services/" rel="noopener noreferrer"&gt;k3d docs — Exposing Services (the &lt;code&gt;HOST:CONTAINER@loadbalancer&lt;/code&gt; mapping)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://k3d.io/v5.7.5/usage/k3s/" rel="noopener noreferrer"&gt;k3d docs — bundled k3s features (Traefik + ServiceLB)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc6761" rel="noopener noreferrer"&gt;RFC 6761 — Special-Use Domain Names (why &lt;code&gt;.localhost&lt;/code&gt; maps to loopback)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/" rel="noopener noreferrer"&gt;Kubernetes docs — DNS for Services and Pods (FQDN pattern)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-networking-and-ingress?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-networking-and-ingress" rel="noopener noreferrer"&gt;End-to-end local k3d networking &amp;amp; Ingress walkthrough&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Zero-Downtime Kubernetes Deployment: Killing Deploy 5xx With k3d Before Prod</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Sat, 08 Aug 2026 17:16:59 +0000</pubDate>
      <link>https://dev.to/mtrnm/zero-downtime-kubernetes-deployment-killing-deploy-5xx-with-k3d-before-prod-1e89</link>
      <guid>https://dev.to/mtrnm/zero-downtime-kubernetes-deployment-killing-deploy-5xx-with-k3d-before-prod-1e89</guid>
      <description>&lt;h1&gt;
  
  
  Zero-Downtime Kubernetes Deployment: Killing Deploy 5xx With k3d Before Prod
&lt;/h1&gt;

&lt;p&gt;Every deploy of our payments API leaked 5xx. A dozen requests, every time, right at the rollout. They retried and succeeded, so nobody paged. We process money. "A dozen dropped requests, they retried" is not a sentence I say to a customer.&lt;/p&gt;

&lt;p&gt;This is a zero-downtime kubernetes deployment story. It has no service mesh in it. The fix was four boring things — readiness probes, real SIGTERM handling, a &lt;code&gt;preStop&lt;/code&gt; sleep, and &lt;code&gt;maxUnavailable: 0&lt;/code&gt; — and I proved every one of them on a laptop k3d cluster before prod ever saw the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;billing-api&lt;/code&gt;. Python, FastAPI, Postgres. Two replicas. Green on every dashboard: pods &lt;code&gt;Running&lt;/code&gt;, CPU flat, error rate a rounding error.&lt;/p&gt;

&lt;p&gt;Then the synthetic checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;2025-... GET /charge 200
2025-... GET /charge 503   &amp;lt;-- rollout starts
2025-... GET /charge 502
2025-... GET /charge 503
2025-... GET /charge 200   &amp;lt;-- rollout ends
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every deploy. A burst of &lt;code&gt;502&lt;/code&gt;/&lt;code&gt;503&lt;/code&gt; that lined up exactly with the new ReplicaSet coming up. For a year I filed it under "cost of rolling updates." Wrong on every count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;I read our Deployment against a production-readiness checklist. It was embarrassing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing-api&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/billing-api:sha-9f2c1a&lt;/span&gt;
          &lt;span class="c1"&gt;# no probes&lt;/span&gt;
          &lt;span class="c1"&gt;# no resources&lt;/span&gt;
          &lt;span class="c1"&gt;# no graceful shutdown&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four independent bugs, each producing 5xx on its own:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No readiness probe. Kubernetes routed traffic to pods before FastAPI had opened its DB pool.&lt;/li&gt;
&lt;li&gt;The container launched via &lt;code&gt;sh -c "uvicorn ..."&lt;/code&gt;. PID 1 was the shell. The app never received SIGTERM.&lt;/li&gt;
&lt;li&gt;Endpoint removal and SIGTERM happen in parallel on pod deletion. Traffic hit pods that had already started shutting down.&lt;/li&gt;
&lt;li&gt;Default &lt;code&gt;RollingUpdate&lt;/code&gt; allowed 25% unavailable — a real capacity dip on two replicas.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a solid, end-to-end breakdown of why each of these matters at &lt;a href="https://dorokhovich.com/blog/local-k8s-making-it-production-like?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-making-it-production-like" rel="noopener noreferrer"&gt;this writeup on making a local cluster production-like&lt;/a&gt;; I won't re-derive the theory here. I'll show the fixes and how I tested them in &lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;k3d&lt;/a&gt; — CNCF's k3s in Docker — before shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Readiness is the gate
&lt;/h3&gt;

&lt;p&gt;Split health into two endpoints. Liveness answers "is the process alive." Readiness answers "can I serve a request right now."&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="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz&lt;/span&gt;&lt;span class="sh"&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;healthz&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# liveness: process is up. Nothing else.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ready&lt;/span&gt;&lt;span class="sh"&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;ready&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# readiness: DB pool reachable? If not, 503.
&lt;/span&gt;    &lt;span class="c1"&gt;# Pod leaves endpoints. It is NOT restarted.
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;ready&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/healthz&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/ready&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model: &lt;strong&gt;liveness restarts, readiness reroutes.&lt;/strong&gt; A readiness failure pulls the pod out of the Service endpoints without killing it — the &lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;Kubernetes probe docs&lt;/a&gt; say so directly. Do not put a DB check in liveness. Postgres flickers, liveness fails, Kubernetes restarts a healthy app, and now you have a restart storm on top of a DB blip. Dependency checks go in readiness. Always.&lt;/p&gt;

&lt;p&gt;Verified on k3d in about ten seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get endpointslices &lt;span class="nt"&gt;-n&lt;/span&gt; billing &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kill the DB connection. Watch the pod drop out of endpoints. Restore it. Watch it come back. No restart. That one experiment taught me more than a year of prod Grafana.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. SIGTERM, PID 1, and the shell that ate the signal
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CMD sh -c "uvicorn app.main:app ..."&lt;/code&gt; makes &lt;code&gt;sh&lt;/code&gt; PID 1. &lt;code&gt;sh&lt;/code&gt; does not forward SIGTERM. The app ran until the 30s grace period elapsed, then got &lt;code&gt;SIGKILL&lt;/code&gt;'d mid-request.&lt;/p&gt;

&lt;p&gt;One line. Exec form. uvicorn becomes PID 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modern uvicorn drains cleanly on SIGTERM: stop accepting, finish in-flight, close the pool, exit — but only if it receives the signal. The &lt;a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/" rel="noopener noreferrer"&gt;Pod lifecycle docs&lt;/a&gt; spell out the sequence: kubelet runs &lt;code&gt;preStop&lt;/code&gt;, sends SIGTERM to PID 1, waits &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;, then SIGKILLs. A shell at PID 1 eats step two.&lt;/p&gt;

&lt;p&gt;Check what your PID 1 actually is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; deploy/billing-api &lt;span class="nt"&gt;-n&lt;/span&gt; billing &lt;span class="nt"&gt;--&lt;/span&gt; ps &lt;span class="nt"&gt;-o&lt;/span&gt; pid,comm
&lt;span class="c"&gt;#   PID COMMAND&lt;/span&gt;
&lt;span class="c"&gt;#     1 uvicorn      &amp;lt;- good&lt;/span&gt;
&lt;span class="c"&gt;#     1 sh           &amp;lt;- your signal is going nowhere&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The endpoints race, and the preStop sleep
&lt;/h3&gt;

&lt;p&gt;Here is the one nobody tells you. On pod deletion, endpoint removal and SIGTERM fire &lt;strong&gt;in parallel&lt;/strong&gt;. For a short window kube-proxy and the ingress still route to a pod that has already begun shutdown. Those requests die. That was the rest of our 5xx. The &lt;a href="https://www.cncf.io/blog/2024/12/19/decoding-the-pod-termination-lifecycle-in-kubernetes-a-comprehensive-guide/" rel="noopener noreferrer"&gt;CNCF pod-termination walkthrough&lt;/a&gt; spells out the race and why a short &lt;code&gt;preStop&lt;/code&gt; sleep is the standard fix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;terminationGracePeriodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing-api&lt;/span&gt;
      &lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;15"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;preStop&lt;/code&gt; sleep buys time for routing to converge before the app tears down.&lt;/p&gt;

&lt;p&gt;Gotcha that nearly bit me: &lt;code&gt;preStop&lt;/code&gt; counts &lt;em&gt;against&lt;/em&gt; the grace period. &lt;code&gt;sleep 30&lt;/code&gt; with a 30s grace budget leaves zero seconds to drain — SIGKILL again. Keep the sleep well under budget. &lt;code&gt;sleep 15&lt;/code&gt;, grace &lt;code&gt;30&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. maxUnavailable: 0
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RollingUpdate&lt;/span&gt;
    &lt;span class="na"&gt;rollingUpdate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;maxSurge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;maxUnavailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
  &lt;span class="na"&gt;minReadySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;maxUnavailable: 0&lt;/code&gt; means the ready-pod count never dips during a rollout — the &lt;a href="https://kubernetes.io/docs/tasks/run-application/update-deployment-rolling/" rel="noopener noreferrer"&gt;rolling-update guide&lt;/a&gt; documents the surge-then-retire dance. &lt;code&gt;maxSurge: 1&lt;/code&gt; brings up a new pod, waits for its readiness, retires an old one, loops. &lt;code&gt;minReadySeconds&lt;/code&gt; guards against pods that report ready and immediately crash. None of it works without the readiness probe from fix 1 — the rollout waits on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying zero downtime locally
&lt;/h2&gt;

&lt;p&gt;Runbook I ran on k3d before touching prod:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spin the cluster: &lt;code&gt;k3d cluster create billing --agents 2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Apply the manifests, wait for &lt;code&gt;kubectl rollout status deployment/billing-api -n billing&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Hammer it in one terminal:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://billing.localhost:8081/&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Trigger a rollout in another: &lt;code&gt;kubectl set image deploy/billing-api billing-api=...:sha-new -n billing&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Watch the curl loop. Any non-&lt;code&gt;200&lt;/code&gt; is a bug still in the manifest.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first few passes still threw 5xx — which told me my readiness-plus-preStop combo wasn't done. I iterated on the laptop, for free, on a Tuesday, instead of debugging it in prod on a Friday night. When it held zero errors through a dozen rollouts locally, I shipped. Prod matched: zero, sustained.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory limit too low = death, not slowdown.&lt;/strong&gt; CPU is compressible (throttled). Memory is not — OOMKilled, exit code &lt;strong&gt;137&lt;/strong&gt;. Set the request near P99 + ~20% headroom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;limit&lt;/code&gt; without &lt;code&gt;request&lt;/code&gt; sets &lt;code&gt;request = limit&lt;/code&gt; silently.&lt;/strong&gt; Inflates scheduler demand until pods sit &lt;code&gt;Pending&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;replicas: 1&lt;/code&gt; is downtime by definition.&lt;/strong&gt; Any restart, drain, or rollout is an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow starters want a startup probe, not a giant &lt;code&gt;initialDelaySeconds&lt;/code&gt;.&lt;/strong&gt; The startup probe holds off liveness/readiness until boot completes, so liveness can stay strict.&lt;/li&gt;
&lt;li&gt;The production-readiness checklist is now a required review item on every Deployment PR. No probes, no shutdown story, no merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Fold all of this into the Helm chart as defaults so no service can ship without probes and a drain story. And stop treating "it retries" as "it works." A retried 5xx is still a 5xx that reached a customer.&lt;/p&gt;

&lt;p&gt;If your deploys leak errors and the team shrugs it off as normal — it isn't. Grab a local cluster and break your own rollout until it stops breaking.&lt;/p&gt;

&lt;p&gt;Bottom line: zero-downtime rollouts aren't a mesh feature, they're four manifest details, and a laptop k3d cluster catches all four before prod does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;Kubernetes docs — Configure Liveness, Readiness and Startup Probes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/" rel="noopener noreferrer"&gt;Kubernetes docs — Pod Lifecycle: termination, preStop and SIGTERM&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/run-application/update-deployment-rolling/" rel="noopener noreferrer"&gt;Kubernetes docs — Update a Deployment without downtime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.cncf.io/blog/2024/12/19/decoding-the-pod-termination-lifecycle-in-kubernetes-a-comprehensive-guide/" rel="noopener noreferrer"&gt;CNCF — Decoding the pod termination lifecycle in Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;k3d — run CNCF's k3s in Docker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-making-it-production-like?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-making-it-production-like" rel="noopener noreferrer"&gt;Full production-like local-k8s writeup&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Real Kubernetes on your laptop in ~2 minutes with k3d (and you can throw it away for free)</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:53:15 +0000</pubDate>
      <link>https://dev.to/mtrnm/reddit-rk3s-rhomelab-and-devto-the-lightweight-real-k8s-locally-story-is-exactly-this-2dm4</link>
      <guid>https://dev.to/mtrnm/reddit-rk3s-rhomelab-and-devto-the-lightweight-real-k8s-locally-story-is-exactly-this-2dm4</guid>
      <description>&lt;p&gt;Chapter 5 of a local-Kubernetes series: the payoff chapter where you spin up an actual cluster. k3d gives you a &lt;em&gt;real&lt;/em&gt; Kubernetes API, not an emulation, and it's so cheap to create and destroy that recreating from scratch is routine.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;k3s vs k3d:&lt;/strong&gt; k3s is a certified, minimalist Kubernetes distro (whole control plane in one binary); k3d is a "lightweight wrapper to run k3s in Docker." Nodes are Docker containers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why it's fast:&lt;/strong&gt; k3s defaults to embedded &lt;strong&gt;SQLite&lt;/strong&gt; instead of etcd — no etcd cluster to stand up, so fast start and low memory. (Multiple server nodes auto-switch to embedded etcd.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one command to keep:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;k3d cluster create dev &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--servers&lt;/span&gt; 1 &lt;span class="nt"&gt;--agents&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--registry-create&lt;/span&gt; k3d-registry.localhost:5000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"8081:80@loadbalancer"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Built-in registry (the fix for chapter 1's &lt;code&gt;ImagePullBackOff&lt;/code&gt;-from-local-Docker) + a forwarded port, in one line.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context gotcha:&lt;/strong&gt; k3d names the context &lt;code&gt;k3d-dev&lt;/code&gt;, not &lt;code&gt;dev&lt;/code&gt;. Always &lt;code&gt;kubectl config current-context&lt;/code&gt; before anything destructive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registry naming:&lt;/strong&gt; k3d prepends &lt;code&gt;k3d-&lt;/code&gt;, so &lt;code&gt;k3d registry create registry.localhost&lt;/code&gt; is referenced as &lt;code&gt;k3d-registry.localhost:5000&lt;/code&gt; in &lt;code&gt;--registry-use&lt;/code&gt; and in &lt;code&gt;image:&lt;/code&gt; fields. Push from host via &lt;code&gt;localhost:5000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port-forwarding via serverlb&lt;/strong&gt; with &lt;code&gt;@loadbalancer&lt;/code&gt; (k3s ships Traefik) — but &lt;strong&gt;port forwards are fixed at creation time&lt;/strong&gt;; to add one you recreate the cluster (cheap in k3d).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest "when to use kind or minikube instead":&lt;/strong&gt; kind for strict upstream parity / conformance; minikube for learning + addons + VM isolation.&lt;/li&gt;
&lt;/ul&gt;

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

</description>
    </item>
    <item>
      <title>Kubernetes Service 'Endpoints: &lt;none&gt;': The One Mismatched Label That Drops All Traffic</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:52:38 +0000</pubDate>
      <link>https://dev.to/mtrnm/kubernetes-service-endpoints-the-one-mismatched-label-that-drops-all-traffic-1c8l</link>
      <guid>https://dev.to/mtrnm/kubernetes-service-endpoints-the-one-mismatched-label-that-drops-all-traffic-1c8l</guid>
      <description>&lt;p&gt;The service was deployed. The Pod was &lt;code&gt;Running&lt;/code&gt;. The Service existed. Zero traffic reached it. When a Kubernetes Service shows &lt;code&gt;Endpoints: &amp;lt;none&amp;gt;&lt;/code&gt;, it is not broken — it's matching nothing, because its selector doesn't match the pod labels. The fix was one hyphen in one YAML file. Understanding why changed how I read every manifest since.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;Three manifests, the ones everyone starts with: a &lt;code&gt;Namespace&lt;/code&gt;, a &lt;code&gt;Deployment&lt;/code&gt;, a &lt;code&gt;Service&lt;/code&gt;. Applied clean. Then a curl through the Service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; ./k8s/
&lt;span class="go"&gt;namespace/myapp created
deployment.apps/myapp created
service/myapp created

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl http://myapp.myapp.svc.cluster.local
&lt;span class="go"&gt;curl: (7) Failed to connect: Connection refused
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error on &lt;code&gt;apply&lt;/code&gt;. Pod healthy. Logs clean. Nothing to grab onto. I'd copied fragments from three tutorials and somewhere in the stitching a label drifted. There's a solid breakdown of the minimal skeleton and how labels glue it together at &lt;a href="https://dorokhovich.com/blog/local-k8s-kubernetes-manifests?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-kubernetes-manifests" rel="noopener noreferrer"&gt;this manifests walkthrough&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;The thing nobody had made click: &lt;strong&gt;nothing connects these objects with hard references.&lt;/strong&gt; No "Service, here are the pod IDs." It's all labels — arbitrary key/value tags — and label selectors matching against them, &lt;a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/" rel="noopener noreferrer"&gt;per the Kubernetes labels-and-selectors concept&lt;/a&gt;. Three separate things reference the same &lt;code&gt;app: myapp&lt;/code&gt; label:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;Deployment&lt;/strong&gt; stamps &lt;code&gt;app: myapp&lt;/code&gt; onto every pod it creates via &lt;code&gt;spec.template.metadata.labels&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The same &lt;strong&gt;Deployment&lt;/strong&gt; uses &lt;code&gt;spec.selector.matchLabels: {app: myapp}&lt;/code&gt; to recognize those pods as its own.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Service&lt;/strong&gt; uses its own &lt;code&gt;spec.selector: {app: myapp}&lt;/code&gt; to find pods, collect their addresses into an EndpointSlice, and balance traffic — &lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/" rel="noopener noreferrer"&gt;the Service docs&lt;/a&gt; describe the controller continuously scanning for matching pods.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two selectors, same pod labels, two problems, and no schema anywhere enforcing that they agree — the string just has to match, character for character. If the Service's selector points at a label no pod has, the Service binds to nothing. It exists, it has an IP, traffic goes nowhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Diagnosis was instant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl describe svc myapp &lt;span class="nt"&gt;-n&lt;/span&gt; myapp
...
Endpoints:   &amp;lt;none&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Deployment stamped &lt;code&gt;app: myapp&lt;/code&gt;; I'd typed &lt;code&gt;app: my-app&lt;/code&gt; in the Service selector. One hyphen. &lt;code&gt;Endpoints: &amp;lt;none&amp;gt;&lt;/code&gt; means the pod label and the Service selector didn't match — full stop. Fixed the selector, re-applied, endpoints populated immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Endpoints:   10.42.0.14:8080,10.42.0.15:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A related rule that would've caught me later, and &lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/deployment/" rel="noopener noreferrer"&gt;the Deployment docs make it hard&lt;/a&gt;: &lt;code&gt;spec.selector.matchLabels&lt;/code&gt; &lt;strong&gt;must&lt;/strong&gt; match &lt;code&gt;spec.template.metadata.labels&lt;/code&gt;, or Kubernetes rejects the manifest at &lt;code&gt;apply&lt;/code&gt; time. Different failure (loud, not silent), same root cause — labels that don't line up.&lt;/p&gt;

&lt;h3&gt;
  
  
  The runbook I use now for "Service, but no traffic"
&lt;/h3&gt;

&lt;p&gt;Whenever a Service exists and nothing reaches it, I run this in order. It localizes the break in under a minute, every time:&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. Does the Service have any endpoints at all?&lt;/span&gt;
kubectl get endpointslices &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;-l&lt;/span&gt; kubernetes.io/service-name&lt;span class="o"&gt;=&lt;/span&gt;myapp
&lt;span class="c"&gt;#    empty -&amp;gt; selector matches nothing. Stop here, fix labels.&lt;/span&gt;

&lt;span class="c"&gt;# 2. What selector is the Service actually using?&lt;/span&gt;
kubectl get svc myapp &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.selector}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;

&lt;span class="c"&gt;# 3. What labels do the pods actually carry?&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;--show-labels&lt;/span&gt;

&lt;span class="c"&gt;# 4. Do they intersect? Ask the API directly with the Service's selector.&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp
&lt;span class="c"&gt;#    no pods listed -&amp;gt; confirmed mismatch between (2) and (3)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 2 and 3 side by side make the mismatch obvious — &lt;code&gt;my-app&lt;/code&gt; next to &lt;code&gt;myapp&lt;/code&gt; jumps out once you're looking at both. Before I had this list I'd start by tcpdumping the pod network, which is exactly the wrong end of the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other trap: ports that don't mean what you think
&lt;/h2&gt;

&lt;p&gt;While in there, I finally understood the port fields I'd been cargo-culting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;          &lt;span class="c1"&gt;# the port the Service listens on&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;  &lt;span class="c1"&gt;# the port on the pod it forwards to&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;port&lt;/code&gt; is where cluster clients connect (80). &lt;code&gt;targetPort&lt;/code&gt; is the pod's actual port (8080). Callers hit &lt;code&gt;myapp&lt;/code&gt; on 80; the app only ever listens on 8080; the Service translates. Also why &lt;code&gt;kubectl port-forward svc/myapp 8080:80&lt;/code&gt; and &lt;code&gt;port-forward pod/... 8080:8080&lt;/code&gt; use different numbers — one targets the Service port, the other the container port.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Symptom&lt;/td&gt;
&lt;td&gt;Service exists, no traffic, no error&lt;/td&gt;
&lt;td&gt;traffic flows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root cause&lt;/td&gt;
&lt;td&gt;selector &lt;code&gt;my-app&lt;/code&gt; vs label &lt;code&gt;myapp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;labels aligned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diagnosis&lt;/td&gt;
&lt;td&gt;hours of guessing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;describe svc&lt;/code&gt; → &lt;code&gt;Endpoints: &amp;lt;none&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mental model&lt;/td&gt;
&lt;td&gt;"objects are wired together"&lt;/td&gt;
&lt;td&gt;"labels are the only glue"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manifests&lt;/td&gt;
&lt;td&gt;pasted from 3 tutorials&lt;/td&gt;
&lt;td&gt;one reviewed &lt;code&gt;k8s/&lt;/code&gt; folder in git&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The namespace traps hiding underneath
&lt;/h2&gt;

&lt;p&gt;Once labels made sense, a second layer did too — all about namespaces. Objects live in a dedicated &lt;code&gt;myapp&lt;/code&gt; namespace, not &lt;code&gt;default&lt;/code&gt; — &lt;a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/" rel="noopener noreferrer"&gt;namespaces&lt;/a&gt; make it easier to delete everything at once, attach limits, and avoid colliding with other teams. But namespaces shape DNS. Services get a name of the form &lt;code&gt;&amp;lt;service&amp;gt;.&amp;lt;namespace&amp;gt;.svc.cluster.local&lt;/code&gt;, &lt;a href="https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/" rel="noopener noreferrer"&gt;per the DNS-for-Services spec&lt;/a&gt;. From the same namespace the short name &lt;code&gt;myapp&lt;/code&gt; works; from a different namespace you need the FQDN &lt;code&gt;myapp.myapp.svc.cluster.local&lt;/code&gt;. A job in another namespace "couldn't reach" the service purely because it used the short name — looks like a networking failure, is really a DNS-scope misunderstanding. The tell is that the error is DNS resolution, not connection refused: &lt;code&gt;could not translate host name "myapp"&lt;/code&gt; means the name never resolved, so the packet never left. &lt;code&gt;nslookup myapp.myapp.svc.cluster.local&lt;/code&gt; from inside a debug pod confirms which form the cluster actually answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Endpoints: &amp;lt;none&amp;gt;&lt;/code&gt; = label/selector mismatch.&lt;/strong&gt; First thing to check when a Service "works" but nothing reaches it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment selector must equal template labels&lt;/strong&gt;, or &lt;code&gt;apply&lt;/code&gt; is rejected outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;-n myapp&lt;/code&gt;&lt;/strong&gt; makes &lt;code&gt;kubectl get&lt;/code&gt; look in &lt;code&gt;default&lt;/code&gt; and report nothing — the "where did everything go?" panic. Pin it once: &lt;code&gt;kubectl config set-context --current --namespace=myapp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;port&lt;/code&gt; vs &lt;code&gt;targetPort&lt;/code&gt; are not interchangeable.&lt;/strong&gt; Backwards and traffic dies at the pod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two selector syntaxes, same meaning.&lt;/strong&gt; The Service writes flat &lt;code&gt;selector: {app: myapp}&lt;/code&gt; (equality-based); the Deployment uses &lt;code&gt;selector.matchLabels&lt;/code&gt; (which can also do &lt;code&gt;matchExpressions&lt;/code&gt;). Different syntax generations, both compare labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl diff -f deployment.yaml&lt;/code&gt; before every apply.&lt;/strong&gt; &lt;code&gt;apply&lt;/code&gt; is declarative and idempotent, easy to run blind; diff catches an accidental label or replica edit before the cluster does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A green &lt;code&gt;apply&lt;/code&gt; proves nothing about routing.&lt;/strong&gt; The three objects can each be valid and still not connect. Endpoints are the only proof that the wiring worked.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;The deeper problem wasn't the hyphen — it was &lt;em&gt;why&lt;/em&gt; I had one. My manifests were fragments on a laptop, applied from the desktop, no history, no review. Now they live in a flat &lt;code&gt;k8s/&lt;/code&gt; folder at the repo root next to the code, applied as a directory with &lt;code&gt;kubectl apply -f ./k8s/&lt;/code&gt;, and every label change goes through a PR where a mismatch like mine is a one-line diff a teammate spots. &lt;code&gt;apply&lt;/code&gt; (declarative) beats &lt;code&gt;create&lt;/code&gt; (imperative) for anything in version control — &lt;code&gt;create&lt;/code&gt; fails if the resource exists and can't update, useless for repeatable manifests — and the &lt;a href="https://kubernetes.io/docs/tasks/manage-kubernetes-objects/declarative-config/" rel="noopener noreferrer"&gt;declarative-config guide&lt;/a&gt; covers the reasoning end to end. I'd have started there instead of pasting from three tutorials.&lt;/p&gt;

&lt;p&gt;Bottom line: when a Service drops all traffic with no error, run &lt;code&gt;kubectl describe svc&lt;/code&gt; and read the Endpoints line — &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; is a label/selector mismatch every time, not a networking failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/" rel="noopener noreferrer"&gt;Kubernetes docs — Service (selectors, EndpointSlices, &lt;code&gt;port&lt;/code&gt; vs &lt;code&gt;targetPort&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/" rel="noopener noreferrer"&gt;Kubernetes docs — Labels and Selectors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/deployment/" rel="noopener noreferrer"&gt;Kubernetes docs — Deployments (selector must match the pod template)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/" rel="noopener noreferrer"&gt;Kubernetes docs — DNS for Services and Pods (the FQDN form)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/" rel="noopener noreferrer"&gt;Kubernetes docs — Namespaces&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/manage-kubernetes-objects/declarative-config/" rel="noopener noreferrer"&gt;Kubernetes docs — Declarative management with &lt;code&gt;kubectl apply&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-kubernetes-manifests?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-kubernetes-manifests" rel="noopener noreferrer"&gt;Full local-k8s manifests walkthrough&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Run Postgres in Kubernetes for Local Development: Killing the docker-compose Parity Gap</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Thu, 06 Aug 2026 09:29:20 +0000</pubDate>
      <link>https://dev.to/mtrnm/run-postgres-in-kubernetes-for-local-development-killing-the-docker-compose-parity-gap-4l4o</link>
      <guid>https://dev.to/mtrnm/run-postgres-in-kubernetes-for-local-development-killing-the-docker-compose-parity-gap-4l4o</guid>
      <description>&lt;p&gt;For a year the app ran in Kubernetes and its Postgres, Redis and RabbitMQ ran in a &lt;code&gt;docker compose up&lt;/code&gt; on the side. It worked until it didn't. If you're going to run Postgres in Kubernetes for local development, do it in the same cluster as the app — two infra descriptions drift, and the drift is where the rollout bugs come from. Here's the migration, including the Helm licensing ambush that nearly derailed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;myapp&lt;/code&gt; (FastAPI on 8080) ran in a k3d &lt;code&gt;dev&lt;/code&gt; cluster, restarted by Tilt on every save. Postgres/Redis/RabbitMQ lived in a &lt;code&gt;compose.yaml&lt;/code&gt; next to it. Two worlds.&lt;/p&gt;

&lt;p&gt;The app reached its DB at &lt;code&gt;localhost:5432&lt;/code&gt; locally but needed a Service DNS name in the cluster. Every deploy, the same class of break:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlalchemy.exc.OperationalError: could not translate host name "localhost"
  to address: Name or service not known
# prod wanted: postgresql://myapp:...@postgres:5432/myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything we'd debugged in Compose — service addresses, env vars, startup order — had to be rewritten from scratch for Kubernetes, and that rewrite was exactly where the bugs lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;Two parallel infra descriptions. In production only the Kubernetes one exists. Anything validated in Compose proved nothing about the manifests that actually ship. This is &lt;a href="https://12factor.net/dev-prod-parity" rel="noopener noreferrer"&gt;the Twelve-Factor dev/prod parity principle&lt;/a&gt;: keep dev and prod the same, especially the tools. There's a solid breakdown of bringing dependencies into the cluster instead of Compose on the side at &lt;a href="https://dorokhovich.com/blog/local-k8s-dependencies-databases-queues?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-dependencies-databases-queues" rel="noopener noreferrer"&gt;this dependencies writeup&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Bring the dependencies up in the same cluster with the same manifests and you get unified DNS (the app finds the DB by service name &lt;code&gt;postgres&lt;/code&gt;, not &lt;code&gt;localhost:5432&lt;/code&gt;), shared Secrets and ConfigMaps, and you catch manifest problems — RBAC, resource limits, probes, network policies — before the rollout, not on a Friday. Heuristic: if the service is going to Kubernetes, run its dependencies in the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ambush: the 2025 Bitnami brownout
&lt;/h2&gt;

&lt;p&gt;The default for stateful dependencies has always been a Helm chart, historically Bitnami (&lt;code&gt;bitnami/postgresql&lt;/code&gt;, etc.). In 2025 that broke. Per &lt;a href="https://github.com/bitnami/charts/issues/35164" rel="noopener noreferrer"&gt;Bitnami's own catalog-change notice&lt;/a&gt;, public image brownouts started 2025-08-28, and from 2025-09-29 most public Bitnami OCI charts and images moved behind a Broadcom commercial subscription. What's left public is limited and only on &lt;code&gt;-latest&lt;/code&gt;; the rest sits in a &lt;code&gt;bitnamilegacy&lt;/code&gt; repo, unsupported, no security patches.&lt;/p&gt;

&lt;p&gt;Blindly running &lt;code&gt;helm install bitnami/*&lt;/code&gt; in 2026 is no longer safe. Options: &lt;a href="https://www.chainguard.dev/supply-chain-security-101/a-practical-guide-to-migrating-helm-charts-from-bitnami" rel="noopener noreferrer"&gt;Chainguard's drop-in replacement charts&lt;/a&gt; (forked for compatibility), official vendor charts, or a deliberately pinned legacy tag (acceptable-ish for local, not prod).&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a raw Postgres manifest
&lt;/h2&gt;

&lt;p&gt;The most reliable dev Postgres turned out not to be Helm at all — a raw three-object manifest, nothing to look up in a chart repo, no subscription dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# postgres.yaml -- Deployment + Service + PVC&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolumeClaim&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;postgres-data&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;myapp&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReadWriteOnce"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1Gi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;myapp&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;postgres&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;postgres&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;myapp&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;myapp&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;devpass&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;PGDATA&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/lib/postgresql/data/pgdata&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/lib/postgresql/data&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-U"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-d"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data&lt;/span&gt;
          &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;postgres-data&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;myapp&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;postgres&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5432&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5432&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;myapp&lt;/code&gt; reaches the DB at &lt;code&gt;postgresql://myapp:devpass@postgres:5432/myapp&lt;/code&gt; by service name — the same shape as prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring Tilt and getting startup order right
&lt;/h2&gt;

&lt;p&gt;Tilt brings dependencies up and tears them down with the project. Ordering is the point:&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;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;postgres.yaml&lt;/span&gt;&lt;span class="sh"&gt;'&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;postgres&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="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;5432:5432&lt;/span&gt;&lt;span class="sh"&gt;'&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;resource_deps&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;postgres&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;&lt;code&gt;resource_deps&lt;/code&gt; makes Tilt wait until &lt;code&gt;postgres&lt;/code&gt; passes its &lt;code&gt;pg_isready&lt;/code&gt; readiness probe before starting &lt;code&gt;myapp&lt;/code&gt;. Verify the ordering held with a quick check — the app should never log a connection error on a cold start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tilt up
kubectl logs deploy/myapp &lt;span class="nt"&gt;-n&lt;/span&gt; myapp | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"could not translate&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;connection refused"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"clean start"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A trap &lt;a href="https://docs.tilt.dev/helm.html" rel="noopener noreferrer"&gt;Tilt's own Helm docs confirm&lt;/a&gt;: Tilt's built-in &lt;code&gt;helm()&lt;/code&gt; is really &lt;code&gt;helm template&lt;/code&gt; and &lt;strong&gt;skips Helm hooks&lt;/strong&gt;, so for third-party charts with initialization you want the &lt;code&gt;helm_resource&lt;/code&gt; extension (a real &lt;code&gt;helm install&lt;/code&gt;). That distinction costs hours if you miss it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surprise that ate an afternoon: ephemeral data
&lt;/h2&gt;

&lt;p&gt;My whole database vanished after &lt;code&gt;k3d cluster delete&lt;/code&gt;. In k3d the default &lt;a href="https://docs.k3s.io/add-ons/storage" rel="noopener noreferrer"&gt;&lt;code&gt;local-path&lt;/code&gt; provisioner k3s ships&lt;/a&gt; writes to &lt;code&gt;/var/lib/rancher/k3s/storage&lt;/code&gt; &lt;strong&gt;inside the k3d node's container filesystem&lt;/strong&gt; — ephemeral by default. Often desirable (clean seed every run). To persist across cluster recreation, map a host directory at creation:&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 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--volume&lt;/span&gt; &lt;span class="nv"&gt;$HOME&lt;/span&gt;/k3d-storage:/var/lib/rancher/k3s/storage@all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PVC detail that confuses everyone once: k3d's &lt;code&gt;local-path&lt;/code&gt; StorageClass uses &lt;code&gt;WaitForFirstConsumer&lt;/code&gt; binding and a &lt;code&gt;reclaimPolicy&lt;/code&gt; of &lt;code&gt;Delete&lt;/code&gt;, and its volumes are node-local — as the &lt;a href="https://github.com/rancher/local-path-provisioner" rel="noopener noreferrer"&gt;local-path-provisioner project&lt;/a&gt; notes, a local-path PVC is pinned to a node via &lt;code&gt;kubernetes.io/hostname&lt;/code&gt;, so the pod always reschedules to the same node. None of it matters until you delete the cluster; then survival comes down entirely to whether you mapped a host dir.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations: a separate Job, not the app's initContainer
&lt;/h2&gt;

&lt;p&gt;An empty Postgres isn't enough — &lt;code&gt;myapp&lt;/code&gt; expects a schema. Tempting to put &lt;code&gt;alembic upgrade head&lt;/code&gt; in &lt;code&gt;myapp&lt;/code&gt;'s initContainer, but with multiple replicas each pod races to migrate, and a long migration risks a probe killing it mid-run. Use a standalone migrations &lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/job/" rel="noopener noreferrer"&gt;&lt;code&gt;Job&lt;/code&gt;&lt;/a&gt;, ordered in Tilt:&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;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-migrate&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource_deps&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;postgres&lt;/span&gt;&lt;span class="sh"&gt;'&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-seed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;resource_deps&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;myapp-migrate&lt;/span&gt;&lt;span class="sh"&gt;'&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;resource_deps&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;myapp-seed&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;Seeding is another Job after migrations — trivial to disable in prod by just not wiring it up. The Job also gives you a clean audit trail: &lt;code&gt;kubectl logs job/myapp-migrate&lt;/code&gt; shows exactly which migrations ran, and a failed migration blocks the app start instead of half-migrating under live traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dependencies&lt;/td&gt;
&lt;td&gt;docker-compose on the side&lt;/td&gt;
&lt;td&gt;in-cluster, like prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB address&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;localhost:5432&lt;/code&gt; locally&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;postgres&lt;/code&gt; service name everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollout surprises&lt;/td&gt;
&lt;td&gt;env/order rewritten for k8s&lt;/td&gt;
&lt;td&gt;caught locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chart risk&lt;/td&gt;
&lt;td&gt;unknowingly on Bitnami&lt;/td&gt;
&lt;td&gt;raw manifest / Chainguard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations&lt;/td&gt;
&lt;td&gt;ad hoc&lt;/td&gt;
&lt;td&gt;separate Job, ordered by Tilt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Make data survival an explicit per-project decision: ephemeral for a clean seed, host-mapped for continuity across &lt;code&gt;k3d cluster delete&lt;/code&gt;. Never a surprise.&lt;/li&gt;
&lt;li&gt;OSS dependencies run as-is in the cluster. Managed services (S3, SQS, DynamoDB) get LocalStack for the fast loop, but verify against a real dev account for anything with subtle semantics — an emulator is never 1:1 with the cloud.&lt;/li&gt;
&lt;li&gt;Audit every &lt;code&gt;helm install&lt;/code&gt; for a Bitnami source before you build on it. That chart may rot underneath you.&lt;/li&gt;
&lt;li&gt;Migrations and seed are separate Jobs, never baked into the app's startup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Move off Compose-on-the-side the day the app went to Kubernetes, not a year later. Every week of two-worlds cost us a rollout surprise that a single cluster would have caught for free.&lt;/p&gt;

&lt;p&gt;Bottom line: run your Postgres/Redis/RabbitMQ in the same cluster as the app so the DB address, startup order, and manifests are validated locally — and audit any Bitnami chart before it rots under you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/bitnami/charts/issues/35164" rel="noopener noreferrer"&gt;Bitnami — upcoming catalog changes (brownouts, &lt;code&gt;bitnamilegacy&lt;/code&gt;, Broadcom subscription)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.chainguard.dev/supply-chain-security-101/a-practical-guide-to-migrating-helm-charts-from-bitnami" rel="noopener noreferrer"&gt;Chainguard — migrating Helm charts from Bitnami (drop-in replacements)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.tilt.dev/helm.html" rel="noopener noreferrer"&gt;Tilt docs — Installing YAML with Helm (&lt;code&gt;helm()&lt;/code&gt; skips hooks; use &lt;code&gt;helm_resource&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.k3s.io/add-ons/storage" rel="noopener noreferrer"&gt;K3s docs — Volumes and Storage (default local-path provisioner)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/rancher/local-path-provisioner" rel="noopener noreferrer"&gt;rancher/local-path-provisioner — node-local dynamic persistent volumes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/job/" rel="noopener noreferrer"&gt;Kubernetes docs — Jobs (run-to-completion workloads)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://12factor.net/dev-prod-parity" rel="noopener noreferrer"&gt;The Twelve-Factor App — Dev/prod parity&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-dependencies-databases-queues?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-dependencies-databases-queues" rel="noopener noreferrer"&gt;Full local-k8s dependencies guide (Postgres/Redis/RabbitMQ in-cluster)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
  </channel>
</rss>
