<?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: Honey Kiran</title>
    <description>The latest articles on DEV Community by Honey Kiran (@honeykiran).</description>
    <link>https://dev.to/honeykiran</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%2F4197128%2Fd0f9d0fa-73f4-4d2d-b866-1218f6e8db9e.jpeg</url>
      <title>DEV Community: Honey Kiran</title>
      <link>https://dev.to/honeykiran</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/honeykiran"/>
    <language>en</language>
    <item>
      <title>My Video Editor Is a Text File Now</title>
      <dc:creator>Honey Kiran</dc:creator>
      <pubDate>Wed, 21 Oct 2026 17:50:13 +0000</pubDate>
      <link>https://dev.to/honeykiran/my-video-editor-is-a-text-file-now-256f</link>
      <guid>https://dev.to/honeykiran/my-video-editor-is-a-text-file-now-256f</guid>
      <description>&lt;p&gt;A headline crossed my feed: “Opus 5.5 Replaced After Effects.” Twelve seconds of a mascot building a title inside a fake After Effects screen.&lt;/p&gt;

&lt;p&gt;My first reaction was the same one I have when a vendor says “zero-downtime migration.” Okay, show me the logs.&lt;/p&gt;

&lt;p&gt;So I tested it. Not on a studio machine with a fancy GPU. On my ordinary laptop: Intel i7, integrated graphics, no gaming card. If a trick only works on a studio rig, it is a hardware advertisement, not a workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the viral clip actually is
&lt;/h2&gt;

&lt;p&gt;The post is by &lt;a href="https://monokern.substack.com/p/opus-55-replaced-after-effects" rel="noopener noreferrer"&gt;Monokern AI&lt;/a&gt;, and to their credit, they explain the route. One 390-word prompt went into Claude Code running Opus 5.5. The fake After Effects interface was built as HTML (a web page) and screenshotted. The 3D scene was written as Blender Python, meaning a script that tells Blender what to build. Then it was rendered in Cycles on a GPU. Render means turning code into finished pictures; Cycles is Blender’s realistic renderer.&lt;/p&gt;

&lt;p&gt;Output: 360 frames at 30 fps, 1920x1080. Finished after three follow-up prompts.&lt;/p&gt;

&lt;p&gt;Here is the part the headline skips. The model did not output a video. It wrote code. The code made pictures. Other tools stitched those pictures into a video. It is code all the way down. That is the whole trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  My test: one brief, ninety frames
&lt;/h2&gt;

&lt;p&gt;My laptop did not even have Blender, ffmpeg or Node installed. Fine. Constraints make good tests.&lt;/p&gt;

&lt;p&gt;I wrote one brief for a 6-second title card for my blog brand, 3AM FILES: black background, orange accent. I gave it to Claude Code on the laptop. Claude wrote ONE self-contained HTML file where the whole animation is a pure function of time. You pass &lt;code&gt;?t=2.80&lt;/code&gt; in the URL and you get exactly what the frame looks like at 2.8 seconds. Pure function means same input, same output, every time, so any frame renders exactly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flu24i2vjolb2yysudlcb.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flu24i2vjolb2yysudlcb.jpeg" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The finished card: the last frame of the 6-second clip.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fua9dyozg04upip9uunjf.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fua9dyozg04upip9uunjf.gif" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then frames. I used headless Chrome, which is the browser running without a visible window, to take screenshots: 90 frames at 15 fps, 960x540. ffmpeg was not there, so GStreamer (another media toolkit, already on the machine) encoded the frames into MP4 using x264enc.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; “Read brief.md and build the animation file”
google-chrome &lt;span class="nt"&gt;--headless&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;new &lt;span class="nt"&gt;--screenshot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;f042.png “file:///.../index.html?t&lt;span class="o"&gt;=&lt;/span&gt;2.80”
gst-launch-1.0 multifilesrc &lt;span class="o"&gt;!&lt;/span&gt; pngdec &lt;span class="o"&gt;!&lt;/span&gt; x264enc &lt;span class="o"&gt;!&lt;/span&gt; mp4mux &lt;span class="o"&gt;!&lt;/span&gt; filesink clip.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middle line is one example from the per-frame loop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzjphlaq1zrjq0r4azk9m.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzjphlaq1zrjq0r4azk9m.jpeg" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwxjgzpo3tzbz7s23y7bb.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwxjgzpo3tzbz7s23y7bb.gif" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The brief itself was written like a deployment plan. Not “make it cool.” Actual beats with timings:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkrb93gru6s2jzpgj3gqy.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkrb93gru6s2jzpgj3gqy.jpeg" width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7uwssfstfvrpbbqp4fp.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7uwssfstfvrpbbqp4fp.jpeg" width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A terminal types a deploy. A CI to K8S to LIVE line draws. The title staggers in. The subtitle fades and the final card holds. All written down before a single frame existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round one was ugly. Round two was a sentence.
&lt;/h2&gt;

&lt;p&gt;Round one worked. Technically. The composition sat in the left half of the frame and the final frame looked unbalanced, like a slide where someone forgot to click “center.”&lt;/p&gt;

&lt;p&gt;In the old world, this is where you open the timeline and start dragging keyframes. A keyframe is a saved position an animator sets by hand. Move one, re-preview, repeat until chai goes cold.&lt;/p&gt;

&lt;p&gt;I wrote one follow-up prompt instead: fix the centering and scale the final composition up. Done. Re-render.&lt;/p&gt;

&lt;p&gt;Total human prompts: two. The brief and the fix. No timeline editor opened. No manual keyframes.&lt;/p&gt;

&lt;p&gt;That is the lesson, and it is bigger than video. You edit words, not keyframes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The timeline did not die. It became a text file.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What this replaces, and what it does not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Manual timeline editing vs this code route:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Making a change —&lt;/strong&gt; Manual: drag keyframes, re-preview, repeat. This code route: change a sentence, re-render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeatability —&lt;/strong&gt; Manual: saving a project file and hoping the app opens it next year. This code route: a text file, re-render any time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best at —&lt;/strong&gt; Manual: footage, fine hand-tuned motion. This code route: titles, diagrams, cards, explainers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardware —&lt;/strong&gt; Manual: wants a strong GPU. This code route: a normal laptop coped.&lt;/p&gt;

&lt;p&gt;Now the honest limits. 15 fps at 960x540 is a proof, not production quality. This route makes motion graphics: titles, diagrams, cards. It does not edit real camera footage. And the GPU/Blender route from the viral post would be slow on integrated graphics, so do not expect that on a laptop like mine.&lt;/p&gt;

&lt;p&gt;Most important: quality comes from how precise the brief is. Timings, positions, easing (how motion speeds up or slows down). The model is not guessing your taste. If your brief is vague, your video will be vague, just faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steal this: the checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Write timings in the brief.&lt;/strong&gt; Beats, seconds, positions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make the animation a function of time.&lt;/strong&gt; One &lt;code&gt;t&lt;/code&gt; parameter in, one exact frame out.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Approve still frames before rendering all frames.&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Render cheap first.&lt;/strong&gt; Low fps, small size. Go big only after it looks right.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fix with words, one change at a time.&lt;/strong&gt; Same discipline as one change per deploy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep the text file as the source of truth.&lt;/strong&gt; Version it like code, because it is code.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The basics did not change
&lt;/h2&gt;

&lt;p&gt;Story, timing and taste still decide whether a video is good. That was true before any of this, and it is true now. What changed is the tool that applies them.&lt;/p&gt;

&lt;p&gt;I used to think motion graphics needed a subscription and a weekend. Now it needs a clear paragraph and a laptop that was never meant for this. Honestly, the clear paragraph is the harder part.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;This post reflects my personal experience and opinions only.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It is not affiliated with, sponsored by, or endorsed by any company named in it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;All product names and trademarks belong to their respective owners.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Facts about other creators’ work are summarized from their public posts, linked in the article.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This is not professional advice; validate everything in your own environment before production use.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;© 2026 Honey Kiran. All rights reserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honey Kiran&lt;/strong&gt; · DevOps &amp;amp; Platform Engineering&lt;/p&gt;

&lt;p&gt;Writing on DevOps, platform engineering, and agents that do the boring work — follow along if 3 AM pages are your love language too.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Examples are illustrative — validate in your own environment before running in production.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>software</category>
      <category>tools</category>
    </item>
    <item>
      <title>Your Agent Wrote 1,800 Tests. Almost None of Them Matter.</title>
      <dc:creator>Honey Kiran</dc:creator>
      <pubDate>Wed, 21 Oct 2026 02:03:38 +0000</pubDate>
      <link>https://dev.to/honeykiran/your-agent-wrote-1800-tests-almost-none-of-them-matter-2487</link>
      <guid>https://dev.to/honeykiran/your-agent-wrote-1800-tests-almost-none-of-them-matter-2487</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21YZr0%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F7a273be3-00b1-4009-8636-3d6ee1e1a400_2048x1152.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21YZr0%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F7a273be3-00b1-4009-8636-3d6ee1e1a400_2048x1152.webp" title="A factory conveyor buried under a glowing pile of test tubes and checklist cards, spilling onto the floor — a test suite that grew until it buried the pipeline that has to run it." alt="A factory conveyor buried under a glowing pile of test tubes and checklist cards, spilling onto the floor — a test suite that grew until it buried the pipeline that has to run it." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over on r/ClaudeCode, in a thread titled “Claude runs 1800 useless tests; creates more against my guidance”, an engineer described the purest agent problem of the year: they told the agent to stop writing so many tests, and the agent — politely, helpfully, immediately — wrote more tests.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Picture the scene — invented, but you have lived a version of it. You ask for tests for one small fix. You go and make chai. You come back to a small army of new test files, all passing. The coverage number (we will get to it) has gone up. CI — continuous integration, the automated checks that run on every change before it can merge — takes noticeably longer. And with 1,800 tests standing guard, you trust the suite &lt;em&gt;less&lt;/em&gt; than you did this morning.&lt;/p&gt;

&lt;p&gt;That feeling is the correct response to a suite that grew in count and shrank in meaning. Here is why it happens, and the guardrails that stop it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent is not disobedient. It is obedient to the wrong score
&lt;/h2&gt;

&lt;p&gt;Start with sympathy, because it makes the fix obvious. An agent — an AI tool that writes and runs code for you, like Claude Code — can see some things clearly and other things not at all.&lt;/p&gt;

&lt;p&gt;A test count is visible. It goes up. It photographs well in a summary: “I added 240 tests, all passing.” Put that next to its honest rival: “I added 6 tests, and decided the other 234 were not worth writing.” The second sentence is better engineering — and much harder for an agent to say, because nothing rewards it.&lt;/p&gt;

&lt;p&gt;Value is invisible at writing time. Whether a test catches a real bug next March is a question about the future; whether it exists is a question about right now. Agents, like all of us on a deadline, optimise for what can be shown. A test is cheap to produce, looks like diligence, and almost never gets argued with in review — who deletes a passing test and feels safe?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; if count is the score the agent can see, make value visible too — and put a ceiling on count. That is what the rest of this post does, one guardrail at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage went up. Confidence did not.
&lt;/h2&gt;

&lt;p&gt;Now the number. Line coverage means exactly this: of all the lines in your project, what percentage were executed — actually run — while the tests ran. That is the whole definition. A coverage tool ticks off every line it sees touched and reports the fraction.&lt;/p&gt;

&lt;p&gt;Notice what is missing. Nothing in that definition asks whether the test &lt;em&gt;checked&lt;/em&gt; anything.&lt;/p&gt;

&lt;p&gt;A test can call a function, assert nothing about the result — an assertion is the line that says “this result must equal that, or fail” — and every line it touched still counts as covered. The Stryker documentation — Stryker is a mutation testing tool, hold that thought — puts it with a sandwich: coverage tells you the bread is 80% covered with paste. It does not tell you whether the paste is chocolate or something you would not serve anyone.&lt;/p&gt;

&lt;p&gt;This is why 1,800 tests can leave you colder than 200 did. The old 200 were written by people with a specific fear in mind: this calculation once shipped wrong, this edge case once bit us. Each was a small promise about one behaviour. The new 1,600 were written to exist. They visit the code. They do not interrogate it.&lt;/p&gt;

&lt;p&gt;And they are not free. Every one runs in CI on every change, forever. Every one can break when you rename something harmless, teaching the team the most dangerous lesson a suite can teach: red does not necessarily mean broken. A team that learns that starts merging on red — and then you do not have a test suite. You have a very slow random number generator with opinions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21cDOR%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F8c432a32-a110-40f4-b6ea-611767ca0c98_2400x1260.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21cDOR%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F8c432a32-a110-40f4-b6ea-611767ca0c98_2400x1260.webp" title="Scorecard: 1,800 shallow tests — coverage high, confidence low, long CI time, few bugs caught, tests that assert little and duplicate a lot — versus 200 load-bearing tests — honest coverage, high confidence, short CI time, one behaviour per test, each one failing first before it earns its place." alt="Scorecard: 1,800 shallow tests — coverage high, confidence low, long CI time, few bugs caught, tests that assert little and duplicate a lot — versus 200 load-bearing tests — honest coverage, high confidence, short CI time, one behaviour per test, each one failing first before it earns its place." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  CLAUDE.md is advice, not a lock
&lt;/h2&gt;

&lt;p&gt;The thread poster’s real complaint was not the first 1,800 tests. It was the “against my guidance” part. They had guidance. It was written down. It was ignored anyway.&lt;/p&gt;

&lt;p&gt;Here the official Claude Code documentation is blunt: CLAUDE.md files — the instruction files you put in a project to tell the agent how to work — are loaded at the start of every session &lt;em&gt;as context, not enforced configuration&lt;/em&gt;. The agent reads it, tries to follow it, and can still drift — especially as the file gets longer (the docs warn that longer files reduce adherence) or when two instructions pull against each other (the docs say the agent may then pick one arbitrarily).&lt;/p&gt;

&lt;p&gt;This is not a flaw to be angry about. It is a category to be clear about. CLAUDE.md is a note on the fridge. Good notes genuinely help. But “please do not eat the cake” is not a lock on the fridge, and the day the cake matters, you will wish you had not confused the two.&lt;/p&gt;

&lt;p&gt;So yes, write the test budget into CLAUDE.md. Write it short, write it specific, write it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## Testing rules
- Budget: at most 10 new tests per change. Need more? Ask first, with a reason.
- One behaviour per test. Name the test after the behaviour, not the function.
- Every new test must fail without the fix and pass with it. No exceptions.
- No test that only checks a mock was called. Assert the result a user would see.
- If a test duplicates an existing one, extend the old test instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A mock, in that line, is a fake stand-in for a real part of the system — a pretend database, a pretend payment service. A test that only checks “the mock was called” proves your code phoned a friend. It says nothing about whether the friend gave the right answer.&lt;/p&gt;

&lt;p&gt;Keep the budget in CLAUDE.md. Just know what it is: a strong request to a reader who usually honours it. For rules that must hold &lt;em&gt;every&lt;/em&gt; time, you need the machinery below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make every new test fail first
&lt;/h2&gt;

&lt;p&gt;Here is the cheapest value-detector in all of testing, and it costs one extra run.&lt;/p&gt;

&lt;p&gt;Before you trust a new test, run it against the code &lt;em&gt;without&lt;/em&gt; the fix. If it passes there, it has told you something devastating: it cannot tell broken from fixed. It would have passed on the day the bug shipped, and it will pass on the day the bug comes back. It is not a guard. It is a spectator with a ticket stub that says “guard”.&lt;/p&gt;

&lt;p&gt;Delete it, or fix it until it fails for the right reason — until the failure message is the bug, wearing a name tag.&lt;/p&gt;

&lt;p&gt;Fail-first also gives the agent a target it cannot fake. “Make it pass” is gameable. “Here is a test that currently fails — make it pass by fixing the code, not by touching the test” is a contract.&lt;/p&gt;

&lt;p&gt;One honest warning: it only works if somebody actually runs the test against the old code. Left as a CLAUDE.md line, it is advice again. Left as a habit in review — “show me this test failing before the fix” — it becomes culture. Culture, unlike context, compounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break your own code on purpose
&lt;/h2&gt;

&lt;p&gt;Now the heavy weapon, and the one that answers the coverage problem directly: mutation testing.&lt;/p&gt;

&lt;p&gt;The idea is beautifully rude. A mutation testing tool — Stryker is the well-known one — takes your production code and deliberately breaks it in small ways, one break at a time. It changes a &lt;code&gt;&amp;gt;=&lt;/code&gt; to a &lt;code&gt;&amp;gt;&lt;/code&gt;. It flips a &lt;code&gt;true&lt;/code&gt; to a &lt;code&gt;false&lt;/code&gt;. Each broken version is called a mutant. Then it runs your tests against each mutant.&lt;/p&gt;

&lt;p&gt;If at least one test fails, the mutant is &lt;em&gt;killed&lt;/em&gt;. Good — your tests noticed. If every test still passes, the mutant &lt;em&gt;survived&lt;/em&gt;, and a survivor is an accusation with an address: here is a specific change to your real code that your entire suite would wave through.&lt;/p&gt;

&lt;p&gt;Against a mutant, there is no rhetoric. Either some test noticed that the age check now lets a younger user through, or none did. As Stryker’s docs put it: the higher the percentage of mutants your tests kill, the more effective your tests are — a claim coverage cannot make about itself.&lt;/p&gt;

&lt;p&gt;Use it with proportion. It re-runs your tests for every mutant, so it is slow — run it on the modules where a silent bug would hurt (money, permissions, the core calculation), not as a toll every small pull request must pay. A toll everyone resents gets removed by Friday. A check aimed at dangerous code survives, because it keeps catching things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the guardrails where they cannot be talked out of
&lt;/h2&gt;

&lt;p&gt;Claude Code gives you two harder layers, both configured in a settings file (&lt;code&gt;.claude/settings.json&lt;/code&gt;) rather than in prose. The first is permissions: allow, ask, and deny rules for what the agent’s tools may do. A deny rule prevents the use it names — rules are evaluated deny first, then ask, then allow, and an allow cannot carve an exception out of a deny. The second is hooks: your own commands that the tool runs automatically at fixed points in its work. A &lt;code&gt;PreToolUse&lt;/code&gt; hook runs &lt;em&gt;before&lt;/em&gt; a tool call executes and can block it — exit code 2 from the hook script means “no, this does not run”. A &lt;code&gt;PostToolUse&lt;/code&gt; hook runs &lt;em&gt;after&lt;/em&gt; a tool succeeds; it cannot un-run anything, but it can react — log it, check it, complain loudly.&lt;/p&gt;

&lt;p&gt;Advice in CLAUDE.md. Enforcement in settings. That sentence is the whole architecture.&lt;/p&gt;

&lt;p&gt;Here is the shape of it, as an illustrative pattern — your script names and thresholds are yours to choose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".claude/hooks/check-test-budget.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Write|Edit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".claude/hooks/note-new-test.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it top to bottom. The deny rules say the agent never reads the secrets file, in any mood, in any session. The &lt;code&gt;PreToolUse&lt;/code&gt; hook inspects a shell command before it runs and can veto it — that is where a script counting new test files against the budget belongs, because a veto cannot be talked out of anything. The &lt;code&gt;PostToolUse&lt;/code&gt; hook cannot veto a file just written, but it can count it and put the running total in front of everyone while there is still time to act.&lt;/p&gt;

&lt;p&gt;Then the last gate, the one nobody can charm: CI itself. A CI gate (a check the build must pass before merging) is team policy in pipeline code. Two gates fit this problem: fail the build when a change adds an absurd number of tests with no explanation, and run the mutation check on critical modules so a suite that only &lt;em&gt;looks&lt;/em&gt; strong cannot merge quietly. The agent does not experience these as suggestions. That is rather the point.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21QeZK%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F0f9bce7d-2171-4df9-8075-7b4fbcf7eafc_2400x1260.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21QeZK%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F0f9bce7d-2171-4df9-8075-7b4fbcf7eafc_2400x1260.webp" title="Flow diagram: Agent writes code and tests, the test suite runs on every change, the CI gate passes or fails the whole build, and only a pass can merge. Below the flow, where the guardrails sit: CLAUDE.md is advice that helps but cannot block; hooks plus deny rules are a hard stop before a tool runs; the mutation check breaks code on purpose so weak tests get caught." alt="Flow diagram: Agent writes code and tests, the test suite runs on every change, the CI gate passes or fails the whole build, and only a pass can merge. Below the flow, where the guardrails sit: CLAUDE.md is advice that helps but cannot block; hooks plus deny rules are a hard stop before a tool runs; the mutation check breaks code on purpose so weak tests get caught." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The test-bloat checklist
&lt;/h2&gt;

&lt;p&gt;Pin this one. Work it the next time an agent offers you a stadium of tests.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Set a test budget:&lt;/strong&gt; A number, per change — at most 10 new tests is a sane start. Over the budget needs a reason, stated first.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One behaviour per test:&lt;/strong&gt; If the name needs the word “and”, it is two tests wearing one coat.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fail first:&lt;/strong&gt; Run every new test against the code without the fix. A test that passes on broken code proves nothing — delete or repair it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Assert the outcome, not the mock:&lt;/strong&gt; Check the result a user would see, not that a fake dependency received a phone call.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Delete the duplicate:&lt;/strong&gt; If a new test guards a behaviour an old test already guards, extend the old test instead.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep CLAUDE.md short and specific:&lt;/strong&gt; It is context, not law — longer files get followed less reliably. Every line must earn its place.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Move must-never rules to hooks and deny:&lt;/strong&gt; Anything that must hold regardless of what the agent decides belongs in settings — a PreToolUse hook that can block, a deny rule that beats every allow.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Gate it in CI:&lt;/strong&gt; Make unexplained suite growth and a falling mutation result on critical code fail the build. A gate does not negotiate.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mutate the critical code:&lt;/strong&gt; Run mutation testing where a silent bug hurts most. Every surviving mutant is a named weakness — fix those before adding any new test.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Review the count, not just the diff:&lt;/strong&gt; Ask out loud, every time: “How many tests did this add, and what does each one promise?”&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;Coverage counts the lines your tests touched. It never asks whether they were holding anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21-sfk%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F2b8c2314-0914-4582-a60a-a518aec700bd_2400x1260.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21-sfk%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F2b8c2314-0914-4582-a60a-a518aec700bd_2400x1260.webp" title="The guardrail cheat sheet — six rules that bind: 1 set a test budget, at most 10 new tests per change; 2 fail first, always — passes with no fix, proves nothing; 3 assert the outcome, not that a mock was called; 4 hooks block, notes advise — must-never rules live in settings; 5 mutate the critical code — surviving mutants mean weak tests; 6 gate growth in CI — a suite that doubles needs a reason." alt="The guardrail cheat sheet — six rules that bind: 1 set a test budget, at most 10 new tests per change; 2 fail first, always — passes with no fix, proves nothing; 3 assert the outcome, not that a mock was called; 4 hooks block, notes advise — must-never rules live in settings; 5 mutate the critical code — surviving mutants mean weak tests; 6 gate growth in CI — a suite that doubles needs a reason." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fewer tests. Stronger promises.
&lt;/h2&gt;

&lt;p&gt;The goal was never 1,800 tests, and it was never 18 either. The goal is a suite you believe on the worst morning of the year — when it goes red at the moment you least want it to, and you stop and look, because its red has always meant something.&lt;/p&gt;

&lt;p&gt;That suite comes from budgeting tests, failing them first, breaking the code underneath them on purpose, and putting the rules that matter where a polite paragraph cannot be talked out of them. Do that, and the next gift the agent brings you will be small, sharp, and worth keeping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staging warning:&lt;/strong&gt; try new hooks, deny rules and CI gates on a staging or throwaway branch first — a PreToolUse hook that blocks too much will block your real work just as faithfully, and you want to learn its temper where it costs nothing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This post describes &lt;strong&gt;my personal experience&lt;/strong&gt; with my own systems and accounts, in my own words. It is a first-person account of what I observed and what I do in my own work — not a statement of any company's intentions, policies, or internal decisions. Where I do not know something, the post says so.&lt;/li&gt;
&lt;li&gt;I am &lt;strong&gt;not affiliated with, endorsed by, or speaking for&lt;/strong&gt; any company, product, or vendor named in this post. All product and company names are &lt;strong&gt;trademarks of their respective owners&lt;/strong&gt; and are mentioned only to describe the experience accurately.&lt;/li&gt;
&lt;li&gt;Any error messages, documentation excerpts, or figures quoted here are reproduced from my own records or from the public sources linked in the post, believed accurate at the time of writing. Product behavior changes over time; verify against current official documentation before relying on any detail.&lt;/li&gt;
&lt;li&gt;Nothing in this post is &lt;strong&gt;professional, financial, legal, or security advice&lt;/strong&gt; for your specific situation. Examples are illustrative — validate in your own environment before running anything in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corrections are welcome.&lt;/strong&gt; If any company named here publishes an official statement, or if any detail is wrong, I will update this post and note the correction. The goal is an accurate public record, not a verdict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;© 2026 Honey Kiran. All rights reserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honey Kiran&lt;/strong&gt; · DevOps &amp;amp; Platform Engineering&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Claude Code, AI agents, CI/CD, testing, and DevOps — explained the practical way.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;How many tests did your agent write this week — and if you deleted half of them tonight, which half would you actually miss? Tell me in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Examples are illustrative — validate in your own environment before running in production.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>OpsBites: Your ‘Private' EKS Cluster Still Bills Like a Public One</title>
      <dc:creator>Honey Kiran</dc:creator>
      <pubDate>Wed, 21 Oct 2026 02:03:24 +0000</pubDate>
      <link>https://dev.to/honeykiran/opsbites-your-private-eks-cluster-still-bills-like-a-public-one-1i1i</link>
      <guid>https://dev.to/honeykiran/opsbites-your-private-eks-cluster-still-bills-like-a-public-one-1i1i</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9nx6g56edk96me4mt7ku.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9nx6g56edk96me4mt7ku.png" alt="Hero image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F628v0b1a5r51jo172jvj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F628v0b1a5r51jo172jvj.gif" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The requirement arrives in one sentence, usually from security: &lt;em&gt;“The cluster must not be reachable from the internet.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fine. Sensible, even. You turn off the public API endpoint, put the nodes in private subnets, and let the engineers in over the VPN. That part of private EKS works exactly as advertised, and AWS documents it well.&lt;/p&gt;

&lt;p&gt;Then somebody — usually finance, usually a month later — asks what “private” costs. And the room goes quiet, because the honest answer is: privacy on AWS is not a setting. It’s a subscription.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Privacy on AWS is not a setting. It’s a subscription.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A thread on r/devops this week asked the classic version of the question: we run EKS inside a corporate environment, our network is on-premise, how do we connect to a private cluster? The connectivity answer is the easy half. Here’s the half that shows up on the invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ‘private’ changes — and what it doesn’t
&lt;/h2&gt;

&lt;p&gt;With the public endpoint disabled, &lt;code&gt;kubectl&lt;/code&gt; from the office reaches the API server privately, over VPN or Direct Connect. Nodes live in subnets with no route to the internet. Good. That’s the security posture everyone wanted.&lt;/p&gt;

&lt;p&gt;But your cluster still talks to AWS all day long. Nodes register with EC2. Pods pull images from ECR. The autoscaler asks STS for tokens. Logs flow to CloudWatch. Load balancers get created. On a public cluster, those calls stroll out through the internet gateway and nobody thinks about them. In a private subnet there is no stroll. Every single one of those calls needs a private path you provision — a VPC endpoint — or it doesn’t happen at all.&lt;/p&gt;

&lt;p&gt;AWS’s own doc for private EKS clusters lists the endpoints a limited-internet cluster needs. Count them with me: &lt;strong&gt;EC2, ECR api, ECR dkr, S3, Elastic Load Balancing, CloudWatch Logs, STS, EKS, EKS Auth, the OIDC endpoint, and Route 53.&lt;/strong&gt; Eleven friends. Most of them are &lt;em&gt;interface&lt;/em&gt; endpoints, and interface endpoints have a price list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fctztvpzcb1d2iks9rz4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fctztvpzcb1d2iks9rz4q.png" alt="Diagram D1" width="799" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fny6kj9am0hj739apxjyl.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fny6kj9am0hj739apxjyl.gif" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The math nobody does in the design meeting
&lt;/h2&gt;

&lt;p&gt;PrivateLink pricing, US regions: an interface endpoint costs &lt;strong&gt;$0.01 per hour, for every AZ you put it in&lt;/strong&gt; , plus &lt;strong&gt;$0.01 per GB&lt;/strong&gt; of data processed. Sounds like nothing. Now do the multiplication everyone skips.&lt;/p&gt;

&lt;p&gt;Line itemThe mathStanding cost / month8 interface endpoints × 3 AZs8 × 3 × $0.01/hr≈ $175EKS control plane$0.10/hr≈ $733 NAT gateways (yes, still)3 × $0.045/hr, before data≈ $991 Application Load Balancer$0.0225/hr, before LCU≈ $16S3 gateway endpointgateway type — free$0 &lt;strong&gt;Total, before any data moves≈ $363&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8pvm1wzp55zw87vjld6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8pvm1wzp55zw87vjld6.png" alt="Diagram D2" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three details in that table deserve a stare:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The S3 endpoint is free&lt;/strong&gt; — because it’s a &lt;em&gt;gateway&lt;/em&gt; endpoint, not an interface one. No hourly charge, no per-GB charge. It’s the only free seat in the house, and it carries the heaviest traffic in the building, because ECR stores image layers in S3. Take it on day one, every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You still pay for NAT.&lt;/strong&gt; Private doesn’t mean nothing ever leaves: package mirrors, external APIs, vendor webhooks. Whatever egress remains rides NAT gateways at $0.045/hr each plus $0.045 per GB — and a partial hour bills as a full one. One per AZ, because a single shared NAT is a cross-AZ fee machine and a quiet single point of failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ALB is cheap; NLB is cheaper; neither is free.&lt;/strong&gt; $0.0225 an hour for either, with LCU charges on top — $0.008 per LCU-hour on the ALB, $0.006 on the NLB. Small numbers, permanent numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the data starts moving
&lt;/h2&gt;

&lt;p&gt;The hourly meter is the rent. The per-GB meter is the toll — and it punishes the lazy route. One terabyte of image pulls through a NAT gateway costs about &lt;strong&gt;$45 in processing fees alone&lt;/strong&gt;. The same terabyte through endpoints costs about &lt;strong&gt;$10&lt;/strong&gt; — and because image layers actually stream out of S3, most of that weight rides the free gateway endpoint anyway. Endpoints in every AZ also keep the traffic local; let an endpoint live in one AZ while your nodes sit in another and you’ll meet cross-AZ transfer charges, the toll booth nobody remembers building.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2cd64kezddbha9qx2ll.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2cd64kezddbha9qx2ll.png" alt="Diagram D3" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest middle
&lt;/h2&gt;

&lt;p&gt;None of this argues against going private. Compliance is real, auditors are realer, and “the Kubernetes API was on the internet” is a sentence nobody wants in an incident report. The bite is narrower:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Price the privacy in the design doc&lt;/strong&gt; , not in the first invoice review. $363 a month standing is nothing to a platform budget — unless nobody budgeted it, in which case it’s a surprise, and surprises are what actually get clusters decommissioned.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Codify the endpoints in Terraform from day one.&lt;/strong&gt; Endpoints added later, by hand, during an outage, are how you end up with three of eleven and a mystery.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dev clusters don’t need the full cathedral.&lt;/strong&gt; Fewer AZs, a shared-endpoint strategy, a smaller footprint — the meter multiplies by AZs, so spend the multiplication where it matters.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three traps that make it worse
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Endpoints in one AZ only.&lt;/strong&gt; Cheaper on the hourly line — until every node in the other two AZs routes its calls across, and the cross-AZ toll quietly eats the saving. It also builds a dependency nobody drew: one AZ hiccup away from pods that can’t pull images.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgetting the S3 gateway endpoint.&lt;/strong&gt; Miss this one free line and ECR layer pulls — the fattest traffic in the cluster — detour through NAT at $0.045 a gigabyte. It’s the most expensive free thing you’ll ever forget to click.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cathedral spec on dev clusters.&lt;/strong&gt; Three AZs, eleven endpoints, full ceremony, for a cluster three people use to test Helm charts. The meter multiplies by AZs whether the cluster is production or a hobby with a pipeline.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Private was never going to be free. It just sends the bill in a shape the design meeting didn’t draw: every endpoint, times every AZ, forever. Draw it anyway — the architecture diagram and the invoice are the same picture, and you’re better off seeing it first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;This post describes &lt;strong&gt;my personal experience&lt;/strong&gt; with my own systems and accounts, in my own words. It is a first-person account of what I observed and what I do in my own work — not a statement of any company’s intentions, policies, or internal decisions. Where I do not know something, the post says so.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I am &lt;strong&gt;not affiliated with, endorsed by, or speaking for&lt;/strong&gt; any company, product, or vendor named in this post. All product and company names are &lt;strong&gt;trademarks of their respective owners&lt;/strong&gt; and are mentioned only to describe the experience accurately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Any error messages, documentation excerpts, or figures quoted here are reproduced from my own records or from the public sources linked in the post, believed accurate at the time of writing. Product behavior changes over time; verify against current official documentation before relying on any detail.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Nothing in this post is &lt;strong&gt;professional, financial, legal, or security advice&lt;/strong&gt; for your specific situation. Examples are illustrative — validate in your own environment before running anything in production.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Corrections are welcome.&lt;/strong&gt; If any company named here publishes an official statement, or if any detail is wrong, I will update this post and note the correction. The goal is an accurate public record, not a verdict.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Examples in this post are illustrative unless marked as documented patterns. Pricing figures are the providers’ published US list prices and vary by region — verify against the current pricing pages before budgeting.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;© 2026 Honey Kiran&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; Kubernetes · Amazon EKS · AWS · VPC · DevOps&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>eks</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your Liveness Probe Is a Kill Switch on a Timer</title>
      <dc:creator>Honey Kiran</dc:creator>
      <pubDate>Wed, 21 Oct 2026 02:02:10 +0000</pubDate>
      <link>https://dev.to/honeykiran/your-liveness-probe-is-a-kill-switch-on-a-timer-f7k</link>
      <guid>https://dev.to/honeykiran/your-liveness-probe-is-a-kill-switch-on-a-timer-f7k</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5kq2r50hs8l2sm43iwj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5kq2r50hs8l2sm43iwj.png" alt="A healthy server rack glowing green in a dark datacenter, while a burning fuse cord runs from a red kill switch on the wall toward it. Slow is not dead — but the fuse is lit." width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgul8ln6y9sfv6fg2hon9.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgul8ln6y9sfv6fg2hon9.gif" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pull up a chair. Today on r/devops, someone posted a thread titled: “Kubernetes probes keep restarting services that just need more time.” I read that title and sighed into my chai, because every engineer who has run Kubernetes for more than a week has watched this exact tragedy.&lt;/p&gt;

&lt;p&gt;You deploy your app. The pod goes &lt;code&gt;Running&lt;/code&gt;. You smile. Ten seconds later the restart counter ticks to 1. Then 2. Then the status flips to &lt;code&gt;CrashLoopBackOff&lt;/code&gt; — Kubernetes throwing its hands up: I tried restarting this thing, it keeps failing, so now everyone waits.&lt;/p&gt;

&lt;p&gt;This Substack is reader-supported. To receive new posts and support my work, consider becoming a free or paid subscriber.&lt;/p&gt;

&lt;p&gt;You check the logs, expecting a stack trace. There is none. The last line says something innocent like &lt;code&gt;Loading configuration...&lt;/code&gt; or &lt;code&gt;Warming up local cache...&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Your app was not broken. It was not deadlocked. It was busy getting ready for work. But the cluster saw a container that had not answered yet, assumed it was dead, and shot it.&lt;/p&gt;

&lt;p&gt;Slow is not dead. Your cluster just cannot tell the difference until you teach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three probes, three questions, three punishments
&lt;/h2&gt;

&lt;p&gt;In Kubernetes, health checks are called probes. They are not run by some wizard in the control plane. They are run by the &lt;strong&gt;kubelet&lt;/strong&gt; — the agent that lives on every node and babysits the containers assigned to it.&lt;/p&gt;

&lt;p&gt;The kubelet runs three probes. They are not interchangeable. Each asks a different question, and each carries a different punishment.&lt;/p&gt;

&lt;p&gt;ProbeQuestion it asksWhat happens when it fails &lt;strong&gt;Startup&lt;/strong&gt; Has it finished starting?Nothing — until its whole budget runs out. Then the container is killed and restarted. &lt;strong&gt;Liveness&lt;/strong&gt; Is it deadlocked?The kubelet kills the container and restarts it, subject to the pod’s restart policy. &lt;strong&gt;Readiness&lt;/strong&gt; Can it take traffic right now?Nothing is killed. The pod’s IP leaves the Service’s endpoints, so traffic stops until it recovers.&lt;/p&gt;

&lt;p&gt;Readiness is the gentle one: no traffic for you until you feel better. It runs for the container’s whole life, and its punishment costs you a few seconds of capacity, never the container.&lt;/p&gt;

&lt;p&gt;Liveness is capital punishment: death and restart. It exists for exactly one situation — an app so deadlocked that no amount of waiting will save it, where the only cure is off and on again.&lt;/p&gt;

&lt;p&gt;Attach that punishment to a container that is still booting, and you have not built a health check. You have built a kill switch on a timer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faff3qhoygs48ih5z5o1q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faff3qhoygs48ih5z5o1q.png" alt="Diagram: the kubelet asks three questions — startup (has it finished starting?), liveness (is it deadlocked?), readiness (can it take traffic right now?) — with very different punishments on failure." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-second fuse
&lt;/h2&gt;

&lt;p&gt;Why does this happen out of the box? The defaults. Every probe carries the same knobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;initialDelaySeconds&lt;/code&gt; — wait before the first check. Default: &lt;strong&gt;0&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;periodSeconds&lt;/code&gt; — how often the check repeats. Default: &lt;strong&gt;10&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;timeoutSeconds&lt;/code&gt; — how long a check may take before it counts as failed. Default: &lt;strong&gt;1&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;successThreshold&lt;/code&gt; — successes needed to call it healthy. Default: &lt;strong&gt;1&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;failureThreshold&lt;/code&gt; — failed checks in a row before the punishment lands. Default: &lt;strong&gt;3&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now add a standard &lt;code&gt;livenessProbe&lt;/code&gt; to a slow-booting app and touch nothing else, because defaults feel harmless. Here is the math the kubelet runs. The container starts; the first check fires almost immediately and fails — the port is not open yet, the app is still reading its config. Strike one. Ten seconds later, strike two — it is loading dependencies now. Ten seconds after that, strike three. Roughly thirty seconds into the boot, the kubelet kills the container and starts a fresh one — which boots the same slow way, and gets shot at the same thirty-second mark.&lt;/p&gt;

&lt;p&gt;Repeat that a few times and the kubelet starts backing off exponentially between restarts. That is your &lt;code&gt;CrashLoopBackOff&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgk4tawqoa6d0ekbtx1bw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgk4tawqoa6d0ekbtx1bw.png" alt="Timeline diagram: with liveness defaults, three failed checks kill a slow-booting container at about 30 seconds, again and again, into CrashLoopBackOff. With a startup probe (30 x 10s = 300s budget), the same app boots in peace and liveness takes over afterwards." width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7w2et40r0atg8lb761z8.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7w2et40r0atg8lb761z8.gif" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The classic band-aid is &lt;code&gt;initialDelaySeconds: 60&lt;/code&gt;. I dislike it. It is a blind guess wearing a number. If the app boots in 8 seconds, the pod sits unchecked for most of a minute. If a busy node or a slow disk stretches one boot to 65 seconds, the guess fails and the kill switch fires anyway.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;A liveness probe should be your last line of defense against a dead process — not a test of whether your app can boot in thirty seconds flat.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix: give the boot its own probe
&lt;/h2&gt;

&lt;p&gt;Kubernetes has a probe built for exactly this: the &lt;strong&gt;startup probe&lt;/strong&gt;. Its rule is simple and beautiful — while a startup probe is configured and has not yet succeeded, Kubernetes does not run the liveness or readiness probes at all. They sit on the bench and wait.&lt;/p&gt;

&lt;p&gt;The startup probe runs only during boot, and its patience is a budget you compute:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Startup budget = failureThreshold × periodSeconds&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The official Kubernetes documentation’s own example sets &lt;code&gt;failureThreshold: 30&lt;/code&gt; and &lt;code&gt;periodSeconds: 10&lt;/code&gt; — 30 × 10 = 300 seconds, a five-minute runway. If your app answers at second 40, the startup probe succeeds once, retires forever, and liveness and readiness take over the watch. If the app truly hangs and burns all thirty checks, only then is it killed.&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;Pod&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;slow-starter-demo&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;app&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;example-app:1.0&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;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
    &lt;span class="na"&gt;startupProbe&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="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/healthz&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;8080&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;30&lt;/span&gt; &lt;span class="c1"&gt;# 30 x 10s = 300s to finish booting&lt;/span&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="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/healthz&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;8080&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="c1"&gt;# strict again — this one is for deadlocks&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="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/ready&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;8080&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;Notice the trick: the startup probe and the liveness probe ask the &lt;em&gt;same&lt;/em&gt; endpoint. The startup probe is just the patient version, standing guard over the boot so liveness never has to be lenient. Your liveness stays tight — a genuinely deadlocked app still gets caught and restarted in about thirty seconds. Fast recovery, no friendly fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Liveness discipline: never check the database
&lt;/h2&gt;

&lt;p&gt;One more trap, and it is the most dangerous one in this whole topic. Kubernetes gives you four probe mechanisms — &lt;code&gt;httpGet&lt;/code&gt; (success means a status from 200 to 399), &lt;code&gt;tcpSocket&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, and &lt;code&gt;grpc&lt;/code&gt; (stable since Kubernetes 1.27). The danger is not the mechanism. It is what your endpoint &lt;em&gt;checks&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The classic mistake: the &lt;code&gt;/healthz&lt;/code&gt; handler checks the process, then pings the database, then the cache, then a downstream API — and returns 500 if any of them sulk. Wire that to a &lt;em&gt;liveness&lt;/em&gt; probe and walk through a database hiccup with me. The database stops accepting connections for a minute. Every pod’s liveness check now fails. Every kubelet in the cluster does its duty and kills its pods — all of them, at roughly the same time. The fresh pods boot straight back into the same dead database, fail the same check, and get killed again, while their boot storms hammer the very database that was trying to recover.&lt;/p&gt;

&lt;p&gt;The Kubernetes documentation warns about this in plain words: incorrect liveness probes lead to cascading failures — restarts under high load, failed client requests, and extra workload dumped on the pods still standing.&lt;/p&gt;

&lt;p&gt;So the rule, stated flat: &lt;strong&gt;liveness checks only the app itself.&lt;/strong&gt; Is the process breathing? Is it deadlocked? Nothing outside the container’s own skin. Dependency checks belong in the &lt;em&gt;readiness&lt;/em&gt; probe — when the database goes down, readiness fails, traffic drains away, and the pod sits quietly with its connections and memory intact, waiting. Nobody gets shot over a dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new slow starters
&lt;/h2&gt;

&lt;p&gt;Slow boots are not a legacy problem. They are getting worse.&lt;/p&gt;

&lt;p&gt;JVM services warming up and apps hydrating big in-memory caches were already blowing past a thirty-second fuse years ago. Now add the newest tenants of our clusters: model servers. A GPU inference server can spend minutes loading model weights into memory before it can answer a single request — and that is a healthy, normal boot. Drop one behind default liveness settings with no startup probe and it will never serve a request in its life. It will load weights, get shot, load weights, get shot.&lt;/p&gt;

&lt;p&gt;The defaults suit a small, fast service. Plenty of what we run in 2026 is neither. Bridging that gap is not the app’s job. It is ours.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftgu52rsnx5nxpdtpvodt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftgu52rsnx5nxpdtpvodt.png" alt="Cheat sheet: for each probe — the question it asks, what it checks, what it must never check, and what happens on failure — plus the budget math and the defaults." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The probe audit
&lt;/h2&gt;

&lt;p&gt;Run your manifests against this list:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Every slow starter has a&lt;/strong&gt; &lt;code&gt;startupProbe&lt;/code&gt; &lt;strong&gt;.&lt;/strong&gt; If a boot takes more than a few seconds, liveness never guards the boot window again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The startup budget covers the worst boot you have measured&lt;/strong&gt; — on a cold, busy node, not on your laptop. Budget = &lt;code&gt;failureThreshold × periodSeconds&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Startup and liveness share one endpoint.&lt;/strong&gt; Same &lt;code&gt;/healthz&lt;/code&gt;, two patience levels. No extra endpoint to maintain.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Liveness checks nothing external.&lt;/strong&gt; No database, no cache, no downstream API. If it reaches outside the container, move that check to readiness.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Readiness checks the real dependencies.&lt;/strong&gt; If the app cannot serve honestly without its database, readiness should know before your users do.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;timeoutSeconds&lt;/code&gt; &lt;strong&gt;is honest.&lt;/strong&gt; A busy single-threaded event loop can easily blow a 1-second timeout under load — and a timeout counts as a failure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Heavy&lt;/strong&gt; &lt;code&gt;exec&lt;/code&gt; &lt;strong&gt;probes stay off dense nodes.&lt;/strong&gt; Every exec check forks processes inside the container; on a crowded node that overhead is real. Prefer &lt;code&gt;httpGet&lt;/code&gt;, &lt;code&gt;tcpSocket&lt;/code&gt;, or &lt;code&gt;grpc&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Alert on restart counts, not just availability.&lt;/strong&gt; A pod killed and restarted all day can still show green between executions. Restarts are the smoke; go look for the fire.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A kubelet has no emotional intelligence. It holds a stopwatch and a restart button, and it does exactly what your numbers tell it to do. So set the numbers like someone who has read them: a startup probe with a real budget for the boot, a liveness probe that only ever asks “are you deadlocked?”, and readiness carrying every dependency check. Slow is not dead — stop letting a thirty-second fuse treat it that way.&lt;/p&gt;




&lt;p&gt;© 2026 Honey Kiran. All rights reserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honey Kiran&lt;/strong&gt; · DevOps &amp;amp; Platform Engineering&lt;/p&gt;

&lt;p&gt;I write about SRE, Kubernetes, Terraform, and platform engineering — the defaults that bite, the incidents that teach, and the guardrails that hold.&lt;/p&gt;

&lt;p&gt;What is the worst boot time hiding in your cluster right now — and which probe is guarding it? Tell me in the replies; the slowest honest answer wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;This post describes &lt;strong&gt;my personal experience&lt;/strong&gt; with my own systems and accounts, in my own words. It is a first-person account of what I observed and what I do in my own work — not a statement of any company’s intentions, policies, or internal decisions. Where I do not know something, the post says so.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I am &lt;strong&gt;not affiliated with, endorsed by, or speaking for&lt;/strong&gt; any company, product, or vendor named in this post. All product and company names are &lt;strong&gt;trademarks of their respective owners&lt;/strong&gt; and are mentioned only to describe the experience accurately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Any error messages, documentation excerpts, or figures quoted here are reproduced from my own records or from the public sources linked in the post, believed accurate at the time of writing. Product behavior changes over time; verify against current official documentation before relying on any detail.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Nothing in this post is &lt;strong&gt;professional, financial, legal, or security advice&lt;/strong&gt; for your specific situation. Examples are illustrative — validate in your own environment before running anything in production.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Corrections are welcome.&lt;/strong&gt; If any company named here publishes an official statement, or if any detail is wrong, I will update this post and note the correction. The goal is an accurate public record, not a verdict.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Examples are illustrative — validate in your own environment before running in production.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This Substack is reader-supported. To receive new posts and support my work, consider becoming a free or paid subscriber.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>sre</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>My AI Agent Drove My iPhone for a Day. Then I Parked It.</title>
      <dc:creator>Honey Kiran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:30:34 +0000</pubDate>
      <link>https://dev.to/honeykiran/my-ai-agent-drove-my-iphone-for-a-day-then-i-parked-it-5901</link>
      <guid>https://dev.to/honeykiran/my-ai-agent-drove-my-iphone-for-a-day-then-i-parked-it-5901</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21d6wR%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Ff963d41b-015c-40f3-bed1-c8baacadad1e_2048x1152.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21d6wR%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Ff963d41b-015c-40f3-bed1-c8baacadad1e_2048x1152.png" title="Hero image" alt="Hero image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Saturday, coffee in hand, the dangerous kind of simple idea in my head.&lt;/p&gt;

&lt;p&gt;On my phone sits an app I reach for constantly — no API, no web version worth the name, everything locked behind its own screens. Every engineer I know is busy wiring AI agents into terminals, browsers and incident channels. But agents that can only talk are half an agent, and the phone is where the world actually transacts. If an agent is ever going to be useful beyond a chat window, it has to work where the work is: inside apps like that one.&lt;/p&gt;

&lt;p&gt;So: give my AI agent (Muse — the same one that runs my blog pipeline) hands on my real iPhone. Open the app, read its screens, tap through it with me in the loop. Eyes and fingers, under orders — nothing gets tapped behind my back.&lt;/p&gt;

&lt;p&gt;Down this road, before you start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The exact build and link fixes for Xcode 27.1 / iOS 27 — including two gates nobody warns you about.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Two small Objective-C patches for private Apple methods iOS 27 silently deleted.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A 14-step copy-paste recipe ending in an automation server on your phone.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The proof: no cable, control over Tailscale, a live screen read, one remote tap.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;And the honest bill — the friction that made me park it the same day.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21Bc9o%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F2319eec5-ebe1-4f32-b0cd-3344473538c1_2400x860.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21Bc9o%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F2319eec5-ebe1-4f32-b0cd-3344473538c1_2400x860.png" title="Diagram D1" alt="Diagram D1" width="800" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted my agent to do
&lt;/h2&gt;

&lt;p&gt;The standard tool for handing an agent an iPhone is WebDriverAgent (WDA). Plain words: a test-runner app that moonlights as a remote control. Born at Facebook, now maintained by the Appium project. You build it in Xcode (Apple’s development environment), sign it with your own Apple ID, and install it on the phone like any app you wrote yourself.&lt;/p&gt;

&lt;p&gt;Once it runs, WDA starts an HTTP server on port 8100. That is the whole trick — the phone becomes a tiny web API. Send a &lt;code&gt;GET&lt;/code&gt; for the source and it walks the current app’s view hierarchy — every button, label and field — and hands it back as XML. Send a &lt;code&gt;POST&lt;/code&gt; with coordinates and it taps the glass. No computer vision, no screenshot-guessing. Just HTTP.&lt;/p&gt;

&lt;p&gt;Or at least, that is how it is supposed to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The morning: a “successful” test that never ran
&lt;/h2&gt;

&lt;p&gt;The rig: a current iPhone on iOS 27.0.1, an Apple-silicon Mac. Even step zero had a cover charge — the Mac had to hop from macOS 26.5.1 to 26.7.1 first, because current Xcode refuses to install below macOS 26.6.&lt;/p&gt;

&lt;p&gt;Then the first self-inflicted wound. The WDA source cloned that morning was branch 3.17 of the old Facebook repo — a vintage tree, discovered to be vintage only in the evening. Maintenance had long moved to Appium. Half the day’s fires trace back to that: old code, brand-new operating system.&lt;/p&gt;

&lt;p&gt;Signing was easy: personal Apple ID, free “Personal Team”, automatic signing, unique bundle IDs — the stock &lt;code&gt;com.facebook.*&lt;/code&gt; IDs can’t be provisioned by a personal team, so they became &lt;code&gt;com.yourname.WebDriverAgentRunner&lt;/code&gt; and &lt;code&gt;com.yourname.WebDriverAgentLib&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Hit build. Three brick walls in under five minutes.&lt;/p&gt;

&lt;p&gt;Wall 1 — the test runs died in 0.6 seconds: &lt;code&gt;IPHONEOS_DEPLOYMENT_TARGET is set to 10.0, supported range is 15.0 to 27.1.x&lt;/code&gt;. Xcode 27 looked at a deployment target from the iOS 10 era and laughed. Both targets went to 15.0.&lt;/p&gt;

&lt;p&gt;Wall 2 — the project’s base config (&lt;code&gt;IOSSettings.xcconfig&lt;/code&gt;) shipped &lt;code&gt;GCC_TREAT_WARNINGS_AS_ERRORS = YES&lt;/code&gt; paired with &lt;code&gt;-Weverything&lt;/code&gt;. Against the new iOS 27 SDK headers, warnings breed like rabbits, and every one was a build error. That went to &lt;code&gt;NO&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Wall 3 — the linker, on Apple’s private automation framework: &lt;code&gt;Cannot link directly with 'XCTAutomationSupport' because product being built is not an allowed client of it&lt;/code&gt;. &lt;code&gt;XCTAutomationSupport&lt;/code&gt; is the engine room under Apple’s UI-testing stack, protected by an allowlist — only binaries presenting an approved client name may link it. The surgery: build &lt;code&gt;WebDriverAgentLib&lt;/code&gt; as a static library (&lt;code&gt;MACH_O_TYPE = staticlib&lt;/code&gt;), link &lt;code&gt;XCTAutomationSupport&lt;/code&gt; into the Runner with the PrivateFrameworks search path, and pass &lt;code&gt;-Wl,-client_name,XCTest&lt;/code&gt; so the bundle presents itself as client “XCTest” — an approved name.&lt;/p&gt;

&lt;p&gt;By midday the build went green: “Testing…”, app installed, Stop button live. I walked away pleased.&lt;/p&gt;

&lt;p&gt;All afternoon Xcode showed the test running, and every fresh start was rejected — “There is already a test running.” When the session record finally closed it showed 20,301.4 seconds: 5.6 hours of “testing.” Nothing was listening on port 8100. No runner process existed on the phone. The evening’s evidence proved the test bundle could not load at all — the morning “success” was a session record that never died, not a server that ever ran.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The build was green, the app was installed, and the test had been “running” for 20,301 seconds. It had never loaded.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21QiVs%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F1741b741-360d-4933-8913-2d7359a4a3bc_2400x940.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21QiVs%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F1741b741-360d-4933-8913-2d7359a4a3bc_2400x940.png" title="Diagram D2" alt="Diagram D2" width="800" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The evening: two gates and a missing framework
&lt;/h2&gt;

&lt;p&gt;That evening I stopped trusting the GUI and asked the phone directly, via &lt;code&gt;devicectl&lt;/code&gt; (Apple’s command-line device tool). Root Cause A, in its own words: &lt;code&gt;Installing cryptex com.apple.MobileAsset.DDI is disallowed because developer mode is not enabled.&lt;/code&gt; (CoreDeviceError 12040/12052)&lt;/p&gt;

&lt;p&gt;Developer Mode was OFF. Without it, the Developer Disk Image — the “DDI”, iOS’s bundle of developer services — cannot mount, the phone lists as &lt;code&gt;connected (no DDI)&lt;/code&gt;, and every launch dies at step 0. Fix: Settings → Privacy &amp;amp; Security → Developer Mode → ON, survive the reboot, confirm again after it.&lt;/p&gt;

&lt;p&gt;Next, Root Cause B. The build now installed the runner; iOS refused to launch it. Xcode’s own alert: &lt;code&gt;The application could not be launched because the Developer App Certificate is not trusted.&lt;/code&gt; Fix: Settings → General → VPN &amp;amp; Device Management → trust the Apple Development certificate under my own Apple ID. Two gates, both on the phone, neither mentioned by any happy-path tutorial I have ever read.&lt;/p&gt;

&lt;p&gt;Then Root Cause C, the real one. A detached launch died instantly (exit code 83), and &lt;code&gt;xcodebuild&lt;/code&gt; showed why: &lt;code&gt;Library not loaded: @rpath/XCTAutomationSupport.framework/XCTAutomationSupport&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We had LINKED the framework but never EMBEDDED it — and it lives in none of the searched places: not the app bundle, not &lt;code&gt;/System/Developer/Library/Frameworks&lt;/code&gt;, not the cryptex copies. It lives in PrivateFrameworks, and nothing had told the binary’s runpath (the &lt;code&gt;@rpath&lt;/code&gt; list the loader searches at launch) to look there. Self-inflicted, too: the static merge made the test binary reference the framework directly, discarding the search paths the stock dynamic library carries.&lt;/p&gt;

&lt;p&gt;The fix: two Runner build settings — add &lt;code&gt;/System/Developer/Library/PrivateFrameworks&lt;/code&gt; and &lt;code&gt;/private/preboot/Cryptexes/OS/System/Developer/Library/PrivateFrameworks&lt;/code&gt; to &lt;code&gt;LD_RUNPATH_SEARCH_PATHS&lt;/code&gt;. On the rebuild: bundle loaded, runner started, automation screen up, and the console printed the line you wait all day for: &lt;code&gt;ServerURLHere-&amp;gt;http://&amp;lt;phone-wifi-ip&amp;gt;:8100&amp;lt;-ServerURLHere&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  iOS 27 removed two methods. We patched both.
&lt;/h2&gt;

&lt;p&gt;The server lived about three and a half minutes before it crashed with an &lt;code&gt;NSInvalidArgumentException&lt;/code&gt;: &lt;code&gt;Enqueue Failure: -[__NSXPCInterfaceProxy_XCTMessagingChannel_RunnerToDaemon _XCT_exchangeProtocolVersion:reply:]: unrecognized selector&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Plain words: WDA asks the phone’s test daemon “which protocol version do you speak?” In iOS 27, the daemon no longer answers that call. The old code — &lt;code&gt;FBTestmanagerdVersion&lt;/code&gt; in &lt;code&gt;FBXCodeCompatibility.m&lt;/code&gt; — asked with no guard and no error handling, so the exception executed the runner. The rude part: the proxy still &lt;em&gt;claims&lt;/em&gt; to support the call. The interface declares it; the daemon just dropped it.&lt;/p&gt;

&lt;p&gt;That number is informational only — it lands in session info, nothing steers by it — so a fallback of 0 is harmless. My agent read the source, found the send site, and patched it: &lt;code&gt;respondsToSelector&lt;/code&gt; check with an early return, the exchange wrapped in &lt;code&gt;@try/@catch&lt;/code&gt;, 0 kept on failure.&lt;/p&gt;

&lt;p&gt;Crash 2 surfaced on the first real screen read (&lt;code&gt;GET /source&lt;/code&gt;): &lt;code&gt;-[XCUIApplicationProcess waitForQuiescenceIncludingAnimationsIdle:]: unrecognized selector&lt;/code&gt;. WDA waits for the app to go idle — “quiescence” — before snapshotting, through another private method iOS 27 removed. There is a settings knob, &lt;code&gt;waitForIdleTimeout&lt;/code&gt;, that sounds exactly like the fix. We set it to 0. Nothing changed: the screen-read path funnels through a different timeout into the same raw send, in &lt;code&gt;XCUIElement+FBUtilities.m&lt;/code&gt; (&lt;code&gt;fb_waitUntilStableWithTimeout:&lt;/code&gt;). The knob exists. The knob is decorative. Same patch: guard it, catch it, and if the method is gone, skip the wait — best-effort.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The pattern that fixes both iOS 27 crashes: never call a private&lt;/span&gt;
&lt;span class="c1"&gt;// method you have not checked for, and never let the check kill you.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt; &lt;span class="nf"&gt;respondsToSelector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;@selector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;thePrivateMethod&lt;/span&gt;&lt;span class="p"&gt;:)])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;@try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt; &lt;span class="nf"&gt;thePrivateMethod&lt;/span&gt;&lt;span class="p"&gt;:];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;@catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSException&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Selector vanished mid-flight — fall back. Do not crash the runner.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The recipe
&lt;/h2&gt;

&lt;p&gt;Everything above, compressed into the checklist I wish had existed on Saturday morning. Every name and value is load-bearing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="c"&gt;# Project / target settings that made it build on Xcode 27.1 / iOS 27
&lt;/span&gt;&lt;span class="nv"&gt;IPHONEOS_DEPLOYMENT_TARGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; 15.0 &lt;span class="c"&gt;# both targets &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;was 10.0&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;GCC_TREAT_WARNINGS_AS_ERRORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; NO &lt;span class="c"&gt;# IOSSettings.xcconfig&lt;/span&gt;
&lt;span class="nv"&gt;MACH_O_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; staticlib &lt;span class="c"&gt;# WebDriverAgentLib only&lt;/span&gt;

&lt;span class="c"&gt;# Runner target
&lt;/span&gt;&lt;span class="nv"&gt;OTHER_LDFLAGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-Wl,-client_name,XCTest"&lt;/span&gt;
&lt;span class="nv"&gt;FRAMEWORK_SEARCH_PATHS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;PLATFORM_DIR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/Developer/Library/PrivateFrameworks"&lt;/span&gt;
&lt;span class="nv"&gt;LD_RUNPATH_SEARCH_PATHS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/System/Developer/Library/PrivateFrameworks"&lt;/span&gt; &lt;span class="s2"&gt;"/private/preboot/Cryptexes/OS/System/Developer/Library/PrivateFrameworks"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Install current Xcode plus the iOS platform component; pair the phone and trust the Mac.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Clone the &lt;strong&gt;maintained Appium&lt;/strong&gt; WebDriverAgent repo — not an old branch of the original.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Personal Team signing on both targets; unique bundle IDs (&lt;code&gt;com.yourname.*&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deployment target &lt;code&gt;15.0&lt;/code&gt; on both targets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;IOSSettings.xcconfig&lt;/code&gt;: &lt;code&gt;GCC_TREAT_WARNINGS_AS_ERRORS = NO&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;WebDriverAgentLib&lt;/code&gt;: &lt;code&gt;MACH_O_TYPE = staticlib&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Runner: link &lt;code&gt;XCTAutomationSupport&lt;/code&gt;, add PrivateFrameworks to &lt;code&gt;FRAMEWORK_SEARCH_PATHS&lt;/code&gt;, and &lt;code&gt;OTHER_LDFLAGS += "-Wl,-client_name,XCTest"&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Runner: &lt;code&gt;LD_RUNPATH_SEARCH_PATHS&lt;/code&gt; += both PrivateFrameworks paths from the settings block.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On the phone: Developer Mode ON (Settings → Privacy &amp;amp; Security; expect a reboot and a second confirmation).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On the phone: trust the developer certificate (Settings → General → VPN &amp;amp; Device Management).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Patch &lt;code&gt;FBXCodeCompatibility.m&lt;/code&gt; (&lt;code&gt;FBTestmanagerdVersion&lt;/code&gt;): &lt;code&gt;respondsToSelector&lt;/code&gt; guard + &lt;code&gt;@try/@catch&lt;/code&gt;, fallback 0.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Patch &lt;code&gt;XCUIElement+FBUtilities.m&lt;/code&gt; (&lt;code&gt;fb_waitUntilStableWithTimeout:&lt;/code&gt;): guard + &lt;code&gt;@try/@catch&lt;/code&gt; around the quiescence send; skip the wait if the method is gone.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Start with the &lt;code&gt;xcodebuild test&lt;/code&gt; command below (~90 seconds to server-up). Optional, once, while cabled: tick “Connect via network” in Xcode’s Devices window so later starts run over Wi-Fi.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Drive it: &lt;code&gt;GET /status&lt;/code&gt; → &lt;code&gt;POST /session&lt;/code&gt; with empty capabilities (target app in the foreground) → &lt;code&gt;GET /source&lt;/code&gt; → &lt;code&gt;POST /wda/tap/0&lt;/code&gt; with coordinates read from the XML.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The proof: no cable, one tap, real data
&lt;/h2&gt;

&lt;p&gt;Control never needed the Mac relay we had built — a USB port-forwarder that never answered cleanly and turned out to be furniture. Plain &lt;code&gt;curl&lt;/code&gt; from the agent’s machine, straight to the phone over Tailscale, the private mesh VPN where every device keeps a stable private address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start (Mac, ~90 seconds to server-up). Full path — bare xcrun may&lt;/span&gt;
&lt;span class="c"&gt;# point at the Command Line Tools and find nothing.&lt;/span&gt;
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-project&lt;/span&gt; ~/Documents/WebDriverAgent/WebDriverAgent.xcodeproj &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-scheme&lt;/span&gt; WebDriverAgentRunner &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-destination&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your-device-udid&amp;gt;

&lt;span class="c"&gt;# Drive it from anywhere (here: over Tailscale, no cable)&lt;/span&gt;
curl http://&amp;lt;phone-tailscale-ip&amp;gt;:8100/status
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://&amp;lt;phone-tailscale-ip&amp;gt;:8100/session &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"capabilities":{"alwaysMatch":{},"firstMatch":[{}]}}'&lt;/span&gt;
curl http://&amp;lt;phone-tailscale-ip&amp;gt;:8100/session/&amp;lt;session-id&amp;gt;/source
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://&amp;lt;phone-tailscale-ip&amp;gt;:8100/session/&amp;lt;session-id&amp;gt;/wda/tap/0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"x":67,"y":904}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GET /status&lt;/code&gt; answered: &lt;code&gt;"WebDriverAgent is ready to accept commands"&lt;/code&gt;, &lt;code&gt;ready: true&lt;/code&gt;, iOS 27.0.1 — and the patch’s fingerprint, &lt;code&gt;"testmanagerdVersion": 0&lt;/code&gt;. It answered again and again. A session was created, and &lt;code&gt;/status&lt;/code&gt; stayed healthy &lt;em&gt;after&lt;/em&gt; session creation — the runner had survived the exact call that killed it before the patch.&lt;/p&gt;

&lt;p&gt;One driving landmine, learned live: creating a session bound to SpringBoard (the home-screen process) timed out and wedged the single-threaded server solid. Attach to a normal app in the foreground. Never target system apps.&lt;/p&gt;

&lt;p&gt;Then the full loop. “Connect via network” was on. The cable run stopped, the cable came OUT, the start command ran again — session hosted over Wi-Fi. The agent attached to a target app (&lt;code&gt;com.example.targetapp&lt;/code&gt;) and read its current screen back live — every label and figure exactly as displayed.&lt;/p&gt;

&lt;p&gt;Element search by accessibility ID is broken on this tree (&lt;code&gt;Invalid key path wdName specified in predicate format string&lt;/code&gt;), so the coordinates came straight from the source XML: a tab’s rectangle, center (67, 904). One &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/wda/tap/0&lt;/code&gt; — the tab switched on the phone in my hand — and the next screen read back cleanly: a list, every row and count intact.&lt;/p&gt;

&lt;p&gt;Read + tap, end to end, zero cables anywhere in the workflow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21vYyN%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fe9184972-e948-4299-835c-8708eda7877e_2400x1240.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21vYyN%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fe9184972-e948-4299-835c-8708eda7877e_2400x1240.png" title="Diagram D3" alt="Diagram D3" width="800" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I parked it the same day
&lt;/h2&gt;

&lt;p&gt;The control never missed. Every status, session, read and tap landed, all evening, over a VPN, on a phone with no cable in it. As a technology demo, flawless.&lt;/p&gt;

&lt;p&gt;And that same evening I told the agent to switch it off and park the whole approach. The bill, unsoftened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;While a session runs, the phone is not your phone.&lt;/strong&gt; The automation screen owns the display. You are carrying a test rig, not a handset. There is no background mode — iOS does not offer one for this.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Every start needs the Mac awake and a human pasting a Terminal command.&lt;/strong&gt; Ninety seconds, yes — but a Mac, a human, and a paste, every single time. That same evening, starting it by driving Xcode’s GUI remotely failed twice. The paste is the only start that ever worked.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Free signing expires every 7 days.&lt;/strong&gt; Miss the weekly re-run and the runner is dead until somebody re-signs it. A paid developer account stretches that to a year — for $99 a year, rent on automating your own pocket hardware.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The stack is an old branch held together by two source patches and build-setting surgery.&lt;/strong&gt; iOS 27 deleted two private methods it stood on and told nobody. The next update can delete two more, and each one costs another evening exactly like this one.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The technology verdict and the ownership verdict are different verdicts. If this ever comes back, it comes back on a dedicated spare phone on a dock, next to an always-on Mac — never on the daily driver. Until then: parked, with the recipe left running in the one place it costs nothing — this page.&lt;/p&gt;

&lt;p&gt;Save the recipe if you are heading down this road anyway. When your agent finally reads your phone screen, enjoy the ten minutes. Then count what it costs to keep.&lt;/p&gt;

&lt;p&gt;© 2026 Honey Kiran. All rights reserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honey Kiran&lt;/strong&gt; · DevOps &amp;amp; Platform Engineering&lt;/p&gt;

&lt;p&gt;I write about DevOps, platform engineering, Kubernetes, and putting AI agents to work — with guardrails, receipts, and the occasional parked project.&lt;/p&gt;

&lt;p&gt;Running an agent against your own phone, or thinking about it? Tell me where yours broke — the replies are where the real field notes collect.&lt;/p&gt;

&lt;p&gt;_&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This post describes &lt;strong&gt;my personal experience&lt;/strong&gt; with my own systems and accounts, in my own words. It is a first-person account of what I observed and what I do in my own work — not a statement of any company's intentions, policies, or internal decisions. Where I do not know something, the post says so.&lt;/li&gt;
&lt;li&gt;I am &lt;strong&gt;not affiliated with, endorsed by, or speaking for&lt;/strong&gt; any company, product, or vendor named in this post. All product and company names are &lt;strong&gt;trademarks of their respective owners&lt;/strong&gt; and are mentioned only to describe the experience accurately.&lt;/li&gt;
&lt;li&gt;Any error messages, documentation excerpts, or figures quoted here are reproduced from my own records or from the public sources linked in the post, believed accurate at the time of writing. Product behavior changes over time; verify against current official documentation before relying on any detail.&lt;/li&gt;
&lt;li&gt;Nothing in this post is &lt;strong&gt;professional, financial, legal, or security advice&lt;/strong&gt; for your specific situation. Examples are illustrative — validate in your own environment before running anything in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corrections are welcome.&lt;/strong&gt; If any company named here publishes an official statement, or if any detail is wrong, I will update this post and note the correction. The goal is an accurate public record, not a verdict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Examples are illustrative — validate in your own environment before running in production._&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>ios</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Cloud Run Is Reliable. Your Defaults Are the Risk.</title>
      <dc:creator>Honey Kiran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:29:06 +0000</pubDate>
      <link>https://dev.to/honeykiran/cloud-run-is-reliable-your-defaults-are-the-risk-47i4</link>
      <guid>https://dev.to/honeykiran/cloud-run-is-reliable-your-defaults-are-the-risk-47i4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8r2cv4qaba1ka48dgcx.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8r2cv4qaba1ka48dgcx.webp" alt="Cloud Run is reliable. Your defaults are the risk. A request flows to a Cloud Run service and its instances, warm or cold, under a 99.95 percent SLA badge — with a dashed cold-start path underneath that nobody budgeted for." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over on r/googlecloud, in a thread titled “How reliable is Cloud Run, really?”, an engineer was doing the homework before a serious move: a mission-critical, latency-sensitive service, currently on Heroku, possibly moving to Cloud Run — and one question underneath it all. Can I trust this thing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is the right question. It is also, forgive me, slightly the wrong question. Cloud Run (Google’s serverless container platform — you give it a container, it runs it, scales it, and bills you for the work) is reliable in the ways Google controls. The part that will actually decide your first month is the part Google does not control: the settings you leave at their defaults because the deploy worked on the first try and everyone went home happy.&lt;/p&gt;

&lt;p&gt;Picture it — illustrative scene, but you have seen this film. The migration goes beautifully. Then the first quiet morning arrives, traffic dips, the platform politely scales everything to zero to save you money, and the first real user of the day pays for it in one slow request. Nobody’s dashboard turns red. The average still looks lovely. Only the slowest users know. They always know.&lt;/p&gt;

&lt;p&gt;So let us read the fine print together. I did the reading so you do not have to lose your afternoon to twelve documentation tabs.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, what the SLA actually promises
&lt;/h2&gt;

&lt;p&gt;SLA means Service Level Agreement — the written promise, with money behind it. For Cloud Run services, Google’s SLA promises a monthly uptime percentage of 99.95% for a normal, non-GPU service in most regions. Stockholm is the odd one out at 99.9%, and a GPU service without zonal redundancy (extra copies spread across zones, so one zone failing does not take you down) sits at 99.5%. Fine numbers. Now read what counts as “down”.&lt;/p&gt;

&lt;p&gt;Downtime, in this document, means an error rate above one percent — and only counting requests that fail with a 5xx error (a server-side failure) because of Cloud Run’s own infrastructure, with a minimum of 100 valid requests in the measurement window. If Google misses, the remedy is a financial credit against future bills — 10%, 25%, or 50% depending on how far it fell — and you have to ask for it within 30 days, with logs.&lt;/p&gt;

&lt;p&gt;Notice two things, because they matter more than the headline number. One: errors caused by your own software are excluded. If your container crashes, that is your downtime, not Google’s, and the SLA stays silent. Two: the SLA counts errors, not slowness. A request that takes nine painful seconds and eventually returns a success counts as uptime. Your user experienced a failure. The contract experienced a triumph. Both are telling the truth, which is exactly why “is Cloud Run reliable?” needs a better follow-up: reliable at what, measured how, and whose settings?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The SLA promises the platform will answer. It never promised your defaults would let it answer fast.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Cold starts: the platform’s politest trap
&lt;/h2&gt;

&lt;p&gt;A cold start is the delay when a request arrives and no running instance (a live copy of your container) exists to take it, so the platform has to start one first — pull things together, boot your process, run your startup code — while the request waits. By default, a Cloud Run revision (one immutable version of your service; every deploy creates a new one) with no traffic scales to zero instances. Zero is beautiful on the bill and brutal on the first request after a lull.&lt;/p&gt;

&lt;p&gt;The fix everybody reaches for is minimum instances: you tell Cloud Run to keep a set number of instances warm and ready even when nothing is arriving. Set it to 1 and the quiet-morning problem mostly disappears for a small service. But be clear about the trade, because there are two catches hiding in it.&lt;/p&gt;

&lt;p&gt;Catch one: warm is not free. Instances kept running by minimum instances do incur billing costs — a lower idle rate if you are on request-based billing, the full rate if you are on instance-based billing. You are renting a taxi that waits outside your house all night. Very convenient. Not charity.&lt;/p&gt;

&lt;p&gt;Catch two: it is a best-effort target, not a guarantee — Google’s own words. Zone capacity trouble, infrastructure rebalancing, a container that crashes on startup, or a quota wall can all leave you temporarily below your floor. So minimum instances reduce cold starts; they do not delete the concept. Design as if a cold start can still happen occasionally — because it can — and make your startup path lean: small image, lazy loading of heavy things you do not need on every request, and startup CPU boost turned on (the &lt;code&gt;--cpu-boost&lt;/code&gt; flag, which gives an instance extra CPU while it starts). Then measure your own cold start in staging. Do not copy anyone else’s number off the internet, including mine. There is no honest universal number; runtimes and images differ too much.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqu61dwplnv19ygpwu1ij.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqu61dwplnv19ygpwu1ij.webp" alt="Architecture diagram: a request reaches the Cloud Run service, which routes by traffic split to either a warm instance of Revision A or down the cold-start path when no warm instance exists, then into your container up to its concurrency limit. The traffic split band shows 95 percent to Revision A and 5 percent to Revision B, with rollback as sending 100 percent back to A." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency: 80 strangers in one small room
&lt;/h2&gt;

&lt;p&gt;Concurrency means how many requests one instance handles at the same time. This is where Cloud Run quietly differs from the old one-request-at-a-time mental model. The console default is 80 concurrent requests per instance, and you can raise it to a maximum of 1,000. If you deploy with the command line or Terraform, a brand-new service defaults to 80 times the number of vCPUs (the virtual CPU cores you gave the container) — so for a 1-vCPU service, that is also 80. Either way, picture it: 80 requests sharing one container, one CPU allowance, one memory allowance, and whatever database connections your code opens.&lt;/p&gt;

&lt;p&gt;For a light, I/O-bound service (one that mostly waits on other systems — a quick database read, an API call, then done), 80 can be perfectly sensible and wonderfully cheap: fewer instances, less bill. For a CPU-heavy service (each request actually crunches — image work, heavy JSON, crypto, reports), 80 is a queue disguised as a setting. Requests do not fail. They wait politely behind each other inside your container, and your latency tail — the slowest few percent of requests, the p95 and p99 your heaviest users live in — grows a long, ugly beard.&lt;/p&gt;

&lt;p&gt;One more nuance: the number is a ceiling, not a promise. If an instance’s CPU is already highly used, Cloud Run may send it fewer requests and scale out instead. And if your code cannot handle parallel requests safely — shared global state, not thread-safe — the docs’ advice is blunt: set concurrency to 1. Correct beats fast.&lt;/p&gt;

&lt;p&gt;Here is a sane starting deploy for a latency-sensitive service — note that every interesting choice is explicit, nothing is left to vibes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy my-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; us-docker.pkg.dev/PROJECT_ID/REPO/my-service:TAG &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--min-instances&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-instances&lt;/span&gt; 20 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--concurrency&lt;/span&gt; 40 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpu&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt; 512Mi &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt; 60s &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpu-boost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it like a contract: one warm instance always ready, at most twenty instances so a spike cannot stampede the database, forty concurrent requests per instance because we measured instead of guessing, and a 60-second timeout matched to the real work. Your numbers will differ. The habit must not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8zzbxxo2equxmboxf1h7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8zzbxxo2equxmboxf1h7.webp" alt="Before and after, illustrative latency shape only — not measured data. On all defaults the typical request is fine but the tail, cold and queued, stretches far. Tuned on purpose — minimum one instance, measured concurrency, startup boost — the typical request is the same and the tail is far shorter." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  CPU only exists while a request is looking at it
&lt;/h2&gt;

&lt;p&gt;This is the default that surprises the most migrants, so sit with it for a second. Cloud Run has two billing settings. The default is request-based billing — the setting previously called “CPU only allocated during request processing”. Exactly what it sounds like: an instance gets CPU while it is processing a request, and is charged when it processes requests, starts, and shuts down. Between requests, your code effectively sits in a dark room.&lt;/p&gt;

&lt;p&gt;The alternative is instance-based billing — previously “CPU always allocated”, switched on with &lt;code&gt;--no-cpu-throttling&lt;/code&gt; — where CPU is allocated for the entire life of the instance and you are charged for that entire life. More money. Also, sometimes, the only correct answer.&lt;/p&gt;

&lt;p&gt;Why? Because of everything your service does after replying. Flush the last log lines, finish the metric, close out the background task — under request-based billing, all of that crawls or stalls the moment the response leaves, because the CPU went home. Like a chef who may only move while a customer is watching: the dish got served; the kitchen never got cleaned. If your service does real background work, choose instance-based billing on purpose — or move that work to a proper queue. Do not discover the dark room in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeouts: the 504 that leaves the body behind
&lt;/h2&gt;

&lt;p&gt;Every request gets a timeout: how long the service may take before Cloud Run gives up on it. The default is 5 minutes (300 seconds), and you can extend it up to a maximum of 60 minutes (3,600 seconds). For most migrated web services this is plenty of headroom — which is precisely why people stop thinking about it, right up until a client, a load balancer, or a framework setting somewhere else in the chain gives up much earlier and everyone debugs the wrong layer.&lt;/p&gt;

&lt;p&gt;And here is the detail worth memorising: on timeout, Cloud Run closes the connection and returns an HTTP 504 — but it does not terminate the container. Your code may keep processing the dead request, burning CPU your live requests needed. So set the timeout just above your honest worst case, bail out early inside long handlers, and if a job regularly wants near an hour, it is asking to be a background job, not a web request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revisions: the part Cloud Run genuinely does beautifully
&lt;/h2&gt;

&lt;p&gt;Now the good news, and it is genuinely good. Every change — new image, new setting — creates a new revision, and Cloud Run lets you choose which revisions get traffic and in what percentage. Deploy the new revision with no traffic, look at it directly, then send it 5%. Watch it. Then 50%, then all of it. If it misbehaves at any point, rollback is not a redeploy and a prayer; it is one traffic command sending 100% back to the previous revision:&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;# deploy the new revision, but give it no traffic yet&lt;/span&gt;
gcloud run deploy my-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; us-docker.pkg.dev/PROJECT_ID/REPO/my-service:TAG &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-traffic&lt;/span&gt; &lt;span class="nt"&gt;--tag&lt;/span&gt; canary

&lt;span class="c"&gt;# let the canary revision taste 5 percent of real traffic&lt;/span&gt;
gcloud run services update-traffic my-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to-tags&lt;/span&gt; &lt;span class="nv"&gt;canary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5

&lt;span class="c"&gt;# rollback: send everything back to the previous revision&lt;/span&gt;
gcloud run services update-traffic my-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to-revisions&lt;/span&gt; &lt;span class="nv"&gt;PREVIOUS_REVISION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two honest caveats. Traffic changes are not instantaneous, and requests already in flight are not dropped during a switch — they finish on whichever revision took them. That is good behaviour, but it means “we rolled back” and “the bad revision stopped mattering” are a few moments apart, not the same instant. And a rollback only rescues you if the previous revision is actually still good and your data changes were backwards-compatible. A new revision writing a new database shape that the old revision cannot read will make your beautiful one-command rollback into a one-command second outage. Rehearse the rollback in staging, with the database change included, before you need it at speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staging warning:&lt;/strong&gt; test every one of these settings in a staging service first — minimum instances, concurrency, timeout, traffic split, and the rollback command itself — with the same defaults you plan for production. A rollback you have never run is a rumour, not a plan.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81nti6w3lincidrxqrvl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81nti6w3lincidrxqrvl.webp" alt="The ten pre-migration reliability questions as a checklist visual: minimum instances, concurrency fit, CPU mode on purpose, timeout fit, maximum instances your database can survive, gradual rollout, a tested rollback, what the SLA counts and excludes, background work finished inside the request, and a load test in staging first." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-migration reliability checklist
&lt;/h2&gt;

&lt;p&gt;Pin this. Ten questions, answered in staging, before Heroku becomes a memory:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Is minimum instances set — or is cold the plan?&lt;/strong&gt; If the service is latency-sensitive, a warm floor (often 1 to start) is a decision, not an accident. Know its idle cost and its best-effort limits.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Does concurrency match your code?&lt;/strong&gt; Load-test at your chosen value. CPU-heavy means lower; light and I/O-bound can go higher; unsafe in parallel means 1.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CPU mode: chosen, or inherited?&lt;/strong&gt; Request-based by default. If anything must run outside a request — background flushes, threads, agents — switch to instance-based billing deliberately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Is the timeout longer than your real work — and not longer?&lt;/strong&gt; Default 5 minutes, maximum 60. Add headroom, add early bail-out, and move hour-long work out of requests entirely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Can your database survive your maximum instances?&lt;/strong&gt; Instances times connections per instance is a number. Compute it before a traffic spike computes it for you, then cap max instances to fit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Does every deploy land with no traffic first?&lt;/strong&gt; New revision dark, checked directly, then 5%, then watched at each step up. No big-bang cutovers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Has the rollback command been run, not just written?&lt;/strong&gt; One command back to the previous revision — rehearsed, with the data change in the rehearsal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do you know what the SLA excludes?&lt;/strong&gt; Your code’s errors are yours. Slow-but-successful is “uptime”. Plan your own error budget on top of the 99.95%.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Is all background work finished inside the request?&lt;/strong&gt; If not, it needs instance-based billing or a queue. Test what happens after the response is sent, not just before.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Did the load test happen in staging first?&lt;/strong&gt; Cold starts, the latency tail, and the rollback — all three observed in staging, none of them debuted in production.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Trust the platform. Audit the defaults.&lt;/strong&gt; So, back to that thread’s question: how reliable is Cloud Run, really? Reliable — measurably, contractually, 99.95%-with-credits reliable — at the layer Google runs. Your service’s reliability is a separate number, written almost entirely in six settings most teams never open: minimum instances, concurrency, CPU allocation, timeout, maximum instances, and how traffic moves between revisions.&lt;/p&gt;

&lt;p&gt;None of this is an argument against migrating. Cloud Run honours the platform bargain — with sharper tools and sharper edges. Keep the bargain. Just read the settings page like it is part of the contract, because for your users, it is the only part they will ever feel.&lt;/p&gt;




&lt;p&gt;© 2026 Honey Kiran. All rights reserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honey Kiran&lt;/strong&gt; · DevOps &amp;amp; Platform Engineering&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cloud, Kubernetes, DevOps, SRE, and platform engineering — explained the practical way.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Moving something latency-sensitive to Cloud Run — or already there and wondering which default is quietly taxing you? Tell me which setting bit you first in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Examples are illustrative — validate in your own environment before running in production.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;This post describes &lt;strong&gt;my personal experience&lt;/strong&gt; with my own systems and accounts, in my own words. It is a first-person account of what I observed and what I do in my own work — not a statement of any company’s intentions, policies, or internal decisions. Where I do not know something, the post says so.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I am &lt;strong&gt;not affiliated with, endorsed by, or speaking for&lt;/strong&gt; any company, product, or vendor named in this post. All product and company names are &lt;strong&gt;trademarks of their respective owners&lt;/strong&gt; and are mentioned only to describe the experience accurately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Any error messages, documentation excerpts, or figures quoted here are reproduced from my own records or from the public sources linked in the post, believed accurate at the time of writing. Product behavior changes over time; verify against current official documentation before relying on any detail.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Nothing in this post is &lt;strong&gt;professional, financial, legal, or security advice&lt;/strong&gt; for your specific situation. Examples are illustrative — validate in your own environment before running anything in production.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Corrections are welcome.&lt;/strong&gt; If any company named here publishes an official statement, or if any detail is wrong, I will update this post and note the correction. The goal is an accurate public record, not a verdict.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>googlecloud</category>
      <category>serverless</category>
      <category>sre</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your Image Tag Says ‘latest’. Your Rollback Says Good Luck.</title>
      <dc:creator>Honey Kiran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:28:00 +0000</pubDate>
      <link>https://dev.to/honeykiran/your-image-tag-says-latest-your-rollback-says-good-luck-3cf5</link>
      <guid>https://dev.to/honeykiran/your-image-tag-says-latest-your-rollback-says-good-luck-3cf5</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9v2zvbzel0cjt3daow2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9v2zvbzel0cjt3daow2.webp" alt="Hero card: Your image tag says 'latest'. Your rollback says good luck. On the right, a list of image references — latest marked as moving, a v1.4.2 release tag and a sha256 digest pin marked as fixed, and yesterday's image marked unknown. Below: Nicknames move. Digests don't." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over on r/devops, a thread titled “How does your organisation handle CI/CD and image tagging for microservices?” asked a simple question — how do you tag your container images across a fleet of services? It was crossposted to r/kubernetes, and the answers split into two camps: teams with a scheme, and teams with a habit. The habit, almost every time, is one word:&lt;/strong&gt; &lt;code&gt;latest&lt;/code&gt; &lt;strong&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Picture the scene — illustrative, but you have lived a version of it. A deploy goes out at night. Something is wrong, nobody knows what yet, and the call is the sensible one: roll back. Somebody re-applies the manifest from before the deploy. The manifest says &lt;code&gt;image: api:latest&lt;/code&gt;. The pods come back green. Everyone exhales.&lt;/p&gt;

&lt;p&gt;Except the rollback rolled nothing back. Between the good deploy and the bad one, another build pushed to &lt;code&gt;latest&lt;/code&gt;. The tag moved. The cluster pulled whatever &lt;code&gt;latest&lt;/code&gt; pointed at that minute — the broken build, or a third build nobody had tested. The team did the right thing, quickly, and production still got a surprise. That is not a people problem. That is a naming problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tag is a nickname, not a name
&lt;/h2&gt;

&lt;p&gt;Start from the floor: this whole mess is two definitions wearing one coat.&lt;/p&gt;

&lt;p&gt;A container image (the packaged app — code, runtime, and files bundled into one runnable unit) lives in a registry (the store that holds images and hands them out when a cluster asks). When you push an image, you give it a tag: a short human-readable label after the colon, like &lt;code&gt;api:1.4.2&lt;/code&gt; or &lt;code&gt;api:latest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the part nobody reads on the box: a tag is just a pointer. By default, tags are mutable (changeable). Push a new image with the same tag and the tag quietly leaves the old image and points at the new one. The old image is still in the registry, but it has lost its label. Kubernetes documentation says it plainly: tags can be moved to point to different images, but digests are fixed.&lt;/p&gt;

&lt;p&gt;The digest is the image’s other identity. It is a SHA-256 hash (a fingerprint computed from the content itself — change even one byte and you get a completely different fingerprint) written as &lt;code&gt;sha256:&lt;/code&gt; followed by 64 characters. You reference it with an &lt;code&gt;@&lt;/code&gt;: &lt;code&gt;api@sha256:...&lt;/code&gt;. A digest does not point at the content. In a real sense, it &lt;em&gt;is&lt;/em&gt; the content’s name, because it is calculated from it.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;latest&lt;/code&gt;? &lt;code&gt;latest&lt;/code&gt; is not special software magic. It is simply the tag you get when you do not specify any tag at all. Push or pull an image with no tag and everyone — Docker, Kubernetes — assumes you mean &lt;code&gt;latest&lt;/code&gt;. It carries no promise about being newest, best, or tested. It only means “whatever this tag pointed at when you asked.”&lt;/p&gt;

&lt;p&gt;One more uncomfortable truth: a tidy tag does not save you. &lt;code&gt;api:1.4.2&lt;/code&gt; looks like a version and reads like a promise, but unless the registry locks it, that tag is exactly as movable as &lt;code&gt;latest&lt;/code&gt;. A nickname that looks formal is still a nickname.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A tag tells you what someone called it. A digest tells you what it actually is.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why ‘latest’ breaks rollbacks first
&lt;/h2&gt;

&lt;p&gt;A rollback (returning production to the previous, known-good version) has one job: run the &lt;em&gt;exact&lt;/em&gt; code that ran before. Not similar code. Not code with the same label. The exact code.&lt;/p&gt;

&lt;p&gt;With a moving tag, you cannot even name that code. “Roll back to &lt;code&gt;latest&lt;/code&gt;“ says nothing, because &lt;code&gt;latest&lt;/code&gt; is a question, not an answer — and the registry will answer it fresh every time you ask. If a build lands mid-incident, your rollback target moves while you are rolling back. It is like giving someone directions to “the blue house” on a street where a painter is working his way down the block.&lt;/p&gt;

&lt;p&gt;Kubernetes makes the trap spring faster. The &lt;code&gt;imagePullPolicy&lt;/code&gt; setting (the rule that decides when the kubelet — the agent on each node that runs pods — downloads an image) is defaulted for you when you leave it out. Leave it out with &lt;code&gt;:latest&lt;/code&gt;, or with no tag at all, and the default is &lt;code&gt;Always&lt;/code&gt;: every new pod re-asks the registry what the tag means right now. Leave it out with a specific tag or a digest, and the default is &lt;code&gt;IfNotPresent&lt;/code&gt;: pull only if the node does not already have it. So a &lt;code&gt;latest&lt;/code&gt; deployment does not even fail consistently — old pods keep the old image, new pods get the new one, and for a while your fleet is running a mixture that exists in no manifest anywhere. Kubernetes’ own guidance is blunt: avoid &lt;code&gt;:latest&lt;/code&gt; in production, because it is harder to track which version is running and harder to roll back properly.&lt;/p&gt;

&lt;p&gt;Then comes the audit question: what code was running at 2 PM? With digests recorded, that is a lookup. With &lt;code&gt;latest&lt;/code&gt;, it is archaeology — push logs, pipeline timelines, and hope. Auditors are not famous for accepting hope as evidence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73rvt3yj7nrzx29we4ue.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73rvt3yj7nrzx29we4ue.webp" alt="Before and after diagram: before, deploying by latest — at 10:00 latest is the good build A, at 11:00 latest is the broken build B, and a rollback to latest at 11:20 gives you B again, or C, who knows. After, deploying by digest — each build keeps its own digest, and rolling back means putting the previous digest back in the manifest, so you get build A again, exactly A." width="800" height="525"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tag like you mean it: SHA plus semver
&lt;/h2&gt;

&lt;p&gt;So what should CI/CD (continuous integration and continuous delivery — the automated pipeline that builds, tests, and ships every change) stamp on each image? Two tags, doing two different jobs.&lt;/p&gt;

&lt;p&gt;First, every build gets the full Git SHA as a tag. The Git SHA (the fingerprint Git gives each commit — each saved snapshot of the source code) is unique to that exact source. Tag &lt;code&gt;api:a91f...&lt;/code&gt; (use the full SHA in real life, not the short form) and anyone can walk from a running image back to the precise code that produced it. No two different builds ever share it, because no two different commits share it. This is the tag for machines and for tracing.&lt;/p&gt;

&lt;p&gt;Second, a release also gets a semver tag, for humans. Semver (semantic versioning) is the three-number scheme &lt;code&gt;MAJOR.MINOR.PATCH&lt;/code&gt;: raise MAJOR for changes that break compatibility, MINOR for features that break nothing, PATCH for backward-compatible bug fixes. The spec also carries a rule that fits this whole post: once a version is released, its contents must not be modified — changes ship as a new version. &lt;code&gt;v1.4.2&lt;/code&gt; tells a human what kind of change this is at a glance. A real job — just not the job of proving identity.&lt;/p&gt;

&lt;p&gt;Here is the shape of it in a pipeline. Read the comments — the last step is the one most pipelines skip, and it is the one that matters most:&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;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.example.com/shop/api
&lt;span class="nv"&gt;SHA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse HEAD&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.4.2

docker build &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$IMAGE&lt;/span&gt;:&lt;span class="nv"&gt;$SHA&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$IMAGE&lt;/span&gt;:v&lt;span class="nv"&gt;$VERSION&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
docker push &lt;span class="nv"&gt;$IMAGE&lt;/span&gt;:&lt;span class="nv"&gt;$SHA&lt;/span&gt;
docker push &lt;span class="nv"&gt;$IMAGE&lt;/span&gt;:v&lt;span class="nv"&gt;$VERSION&lt;/span&gt;

&lt;span class="c"&gt;# Capture the digest - this is the identity you deploy&lt;/span&gt;
docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{index .RepoDigests 0}}'&lt;/span&gt; &lt;span class="nv"&gt;$IMAGE&lt;/span&gt;:&lt;span class="nv"&gt;$SHA&lt;/span&gt;
&lt;span class="c"&gt;# Illustrative output:&lt;/span&gt;
&lt;span class="c"&gt;# registry.example.com/shop/api@sha256:1a2b3c...(full 64-character hash in real output)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two tags go up: the SHA tag, unique forever, and the release tag, readable by humans. Then the pipeline captures the digest from the pushed image and records it with the build — in the build output, the release notes, wherever your team actually looks. That recorded digest is now the deployable fact. Everything after this point should use it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc95vkg45k126t70r384j.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc95vkg45k126t70r384j.webp" alt="Flow diagram: from commit to cluster. CI builds a Git commit, the registry stores the image under its Git SHA tag and its v1.4.2 release tag, the deploy file carries name, tag and sha256 digest together, and the cluster's kubelet pulls the digest every time. A tag is a movable pointer; a digest is a SHA-256 hash of the content itself." width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy the digest, not the nickname
&lt;/h2&gt;

&lt;p&gt;Now the deploy side. A Deployment (the Kubernetes object that keeps a set of identical pods running and replaces them when the spec changes) should carry the image in its full form: name, tag, &lt;em&gt;and&lt;/em&gt; digest, joined like this: &lt;code&gt;name:tag@sha256:digest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why keep the tag in the string at all? For the human reading the manifest at 2 AM — the tag says “release 1.4.2.” But when tag and digest are both present, Kubernetes pulls only by the digest. The tag is a comment living inside the address. Humans read the nickname; the machine obeys the fingerprint.&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;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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&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;shop&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;3&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;matchLabels&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;api&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="na"&gt;labels&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;api&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;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;registry.example.com/shop/api:1.4.2@sha256:REPLACE_WITH_THE_DIGEST_FROM_YOUR_BUILD&lt;/span&gt;
          &lt;span class="na"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IfNotPresent&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;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;imagePullPolicy: IfNotPresent&lt;/code&gt; sitting happily next to the digest pin. That pairing makes sense precisely because a digest never changes: if the node already holds that exact fingerprint, there is nothing newer to fetch — newer does not exist. Compare that with &lt;code&gt;Always&lt;/code&gt; plus &lt;code&gt;latest&lt;/code&gt;, where every pod start is a fresh roll of the dice.&lt;/p&gt;

&lt;p&gt;And rollback? Rollback becomes the most boring operation in your runbook: take the previous recorded digest, put it back in the manifest, apply. No re-pushing old images. No re-tagging anything to make yesterday’s build wear today’s label. No guessing. Boring is not a side effect here. Boring is the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock the registry door — and sign it, if you can
&lt;/h2&gt;

&lt;p&gt;Everything above is discipline. Discipline is good, but discipline that a tired human can accidentally break at release time needs a lock behind it. Registries sell exactly that lock: immutable tags (tags the registry itself refuses to move once pushed).&lt;/p&gt;

&lt;p&gt;On Amazon ECR (Amazon’s container registry), the tag setting defaults to MUTABLE — overwrites allowed. Set &lt;code&gt;ImageTagMutability&lt;/code&gt; to IMMUTABLE and a second push under an existing tag is refused, with an &lt;code&gt;ImageTagAlreadyExistsException&lt;/code&gt;. The mistake stops being silent and becomes a loud, cheap failure in the pipeline, where failures belong. On Google Artifact Registry (Google Cloud’s registry), immutable tags mean you cannot delete a tagged image, remove a tag, move a tag to another digest, or push a tag another version already uses. Docker Hub offers the same idea — all tags immutable, or only specific ones.&lt;/p&gt;

&lt;p&gt;Two honest warnings before you flip the switch on everything you own. First, flip it repository by repository, and tell the team before you do — the first person to learn about it should not learn from a failed release push. If your flow genuinely needs one moving label in development, keep that in a development repository and keep production locked. Second —&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staging warning:&lt;/strong&gt; test the whole flow in staging first. Turn immutability on in a staging repository, push the same tag twice on purpose, and watch the second push fail. Then practise a digest rollback there. A lock you have never tested is just a new way to be surprised, and production charges full price for surprises that staging would have given you free.&lt;/p&gt;

&lt;p&gt;If you can go one step further, sign the image. Signing (attaching a cryptographic signature — a tamper-evident seal proving who produced an artifact and that it has not changed since) is what cosign, from the Sigstore project, does for container images. The rule that matters: always sign the digest, never just the tag — sign a tag and you might seal something you did not intend, because the tag can move after you sign it. Verification then checks that the signature’s digest matches the image in front of it. Signing does not fix tagging. It sits on top of digest pinning and answers the next question: not just “is this the exact image?” but “is this the exact image &lt;em&gt;we&lt;/em&gt; built?”&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;# Sign the digest, never just the tag&lt;/span&gt;
cosign sign &lt;span class="nt"&gt;--key&lt;/span&gt; cosign.key registry.example.com/shop/api@sha256:REPLACE_WITH_THE_DIGEST_FROM_YOUR_BUILD

&lt;span class="c"&gt;# Verify before anything deploys it&lt;/span&gt;
cosign verify &lt;span class="nt"&gt;--key&lt;/span&gt; cosign.pub registry.example.com/shop/api@sha256:REPLACE_WITH_THE_DIGEST_FROM_YOUR_BUILD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The tagging checklist
&lt;/h2&gt;

&lt;p&gt;Pin this where your pipeline lives. Work it top to bottom for one service first — one service done properly teaches the whole fleet the pattern.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Never deploy&lt;/strong&gt; &lt;code&gt;latest&lt;/code&gt; &lt;strong&gt;:&lt;/strong&gt; Not in production, not in staging — &lt;code&gt;latest&lt;/code&gt;, or no tag at all, is for a quick test on your own laptop and nothing else.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tag every CI build with the full Git SHA:&lt;/strong&gt; Unique, automatic, and traceable back to the exact source that produced the image.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add a semver tag on releases, for humans:&lt;/strong&gt; &lt;code&gt;v1.4.2&lt;/code&gt; tells people what kind of change shipped — MAJOR breaks, MINOR adds, PATCH fixes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Record the digest from every build:&lt;/strong&gt; Capture it in the build output and keep it with the release record. No record, no rollback.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deploy by digest:&lt;/strong&gt; The manifest carries &lt;code&gt;name:tag@sha256:digest&lt;/code&gt; — humans read the tag, Kubernetes pulls the digest.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Roll back by restoring the previous digest:&lt;/strong&gt; Never by re-pushing or re-tagging an old image to make it look current.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Turn on immutable tags in the registry:&lt;/strong&gt; ECR IMMUTABLE, Artifact Registry immutable tags, or Docker Hub’s setting — repository by repository.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test in staging first:&lt;/strong&gt; Push the same tag twice and watch it fail; practise the digest rollback before production needs it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sign the digest if your pipeline can:&lt;/strong&gt; cosign sign the digest, verify before deploy — proof of what it is and whose it is.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep&lt;/strong&gt; &lt;code&gt;latest&lt;/code&gt; &lt;strong&gt;in its place:&lt;/strong&gt; If you keep it at all, it is a convenience label in development only — never a deploy reference, never a rollback plan.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm86jb9pj8eo7aajfqsgd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm86jb9pj8eo7aajfqsgd.webp" alt="The tagging scheme on one card, six tiles: tag every build with the full Git SHA; tag releases for humans with semver; record the digest from the build output; deploy the digest in the manifest; lock the registry with immutable tags; sign the digest with cosign and verify before deploy." width="799" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Make rollback boring again
&lt;/h2&gt;

&lt;p&gt;None of this is exotic: a SHA tag, a semver tag, a recorded digest, a manifest that deploys the digest, a registry that refuses to move tags, a signature if you can. An afternoon of pipeline work — mostly deleting habits, not adding machinery.&lt;/p&gt;

&lt;p&gt;What you get back is what &lt;code&gt;latest&lt;/code&gt; quietly stole: certainty. What is running? That digest. What ran yesterday? The previous digest. Roll it back — done, because the previous digest was written down before anyone needed it.&lt;/p&gt;

&lt;p&gt;Let &lt;code&gt;latest&lt;/code&gt; live in development, where surprises are cheap tuition. In production, let the manifest say the fingerprint. Nicknames are for friends. Rollbacks need names.&lt;/p&gt;




&lt;p&gt;© 2026 Honey Kiran. All rights reserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honey Kiran&lt;/strong&gt; · DevOps &amp;amp; Platform Engineering&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Kubernetes, Docker, CI/CD, DevOps, SRE, and platform engineering — explained the practical way.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Quick check before you close this tab: what do your production manifests reference right now — &lt;code&gt;latest&lt;/code&gt;, a tag, or a digest? Tell me in the comments, and if it is &lt;code&gt;latest&lt;/code&gt;, tell me what your rollback plan calls it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Examples are illustrative — validate in your own environment before running in production.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;This post describes &lt;strong&gt;my personal experience&lt;/strong&gt; with my own systems and accounts, in my own words. It is a first-person account of what I observed and what I do in my own work — not a statement of any company’s intentions, policies, or internal decisions. Where I do not know something, the post says so.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I am &lt;strong&gt;not affiliated with, endorsed by, or speaking for&lt;/strong&gt; any company, product, or vendor named in this post. All product and company names are &lt;strong&gt;trademarks of their respective owners&lt;/strong&gt; and are mentioned only to describe the experience accurately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Any error messages, documentation excerpts, or figures quoted here are reproduced from my own records or from the public sources linked in the post, believed accurate at the time of writing. Product behavior changes over time; verify against current official documentation before relying on any detail.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Nothing in this post is &lt;strong&gt;professional, financial, legal, or security advice&lt;/strong&gt; for your specific situation. Examples are illustrative — validate in your own environment before running anything in production.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Corrections are welcome.&lt;/strong&gt; If any company named here publishes an official statement, or if any detail is wrong, I will update this post and note the correction. The goal is an accurate public record, not a verdict.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>docker</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Your Terragrunt CI Re-Downloads the Internet on Every Run</title>
      <dc:creator>Honey Kiran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:26:52 +0000</pubDate>
      <link>https://dev.to/honeykiran/your-terragrunt-ci-re-downloads-the-internet-on-every-run-333e</link>
      <guid>https://dev.to/honeykiran/your-terragrunt-ci-re-downloads-the-internet-on-every-run-333e</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzv6g7rhltbo0h1qe0sb9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzv6g7rhltbo0h1qe0sb9.png" alt="Many CI runner pods reaching into one shared glowing cache vault of provider plugins instead of downloading from a distant cloud." width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Somebody on r/Terraform is watching their Terragrunt CI re-download the internet on every single run — and every obvious fix has a trap in it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The setup, from the thread: a multi-component Terragrunt project (Terragrunt is a wrapper that keeps Terraform code DRY — don’t repeat yourself — across many components), CI on GitLab running on Kubernetes runners, providers and modules re-downloaded every run. Downloads are slow and sometimes flaky. They tried caching to S3 as compressed archives — the compress and decompress time ate the saving. Pre-baking every provider into the runner image? By their estimate that image lands around 80 GB. A shared EFS volume is now on the table.&lt;/p&gt;

&lt;p&gt;Friend, you do not have a download problem. You have a cache-placement problem. And Terraform has a very specific opinion about where provider binaries may safely be shared — ignore it and your parallel runs will corrupt each other’s downloads in fun, non-reproducible ways.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every init downloading the same provider is not thoroughness. It is your pipeline paying the same toll twenty times a day.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is actually being downloaded, again and again
&lt;/h2&gt;

&lt;p&gt;A provider is the plugin binary that lets Terraform talk to a cloud — the AWS provider alone is a large binary, and a multi-component project downloads the same providers once per component, per run, because each component initialises in its own working directory. Modules (reusable Terraform building blocks) get fetched per component too, into each unit’s &lt;code&gt;.terragrunt-cache&lt;/code&gt;. Multiply by every pipeline, every day. That is the bill — in minutes, and in flaky failures when a registry hiccup fails a job that changed three lines.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgxf2of125yqgi209uz13.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgxf2of125yqgi209uz13.png" alt="Architecture diagram: GitLab CI Kubernetes runners feeding a Terragrunt provider cache server with a shared cache directory, registry only on a true miss." width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the plain plugin cache directory, shared by parallel runs
&lt;/h2&gt;

&lt;p&gt;Terraform’s own shared plugin cache — &lt;code&gt;plugin_cache_dir&lt;/code&gt; in the CLI config, or the &lt;code&gt;TF_PLUGIN_CACHE_DIR&lt;/code&gt; environment variable — sounds like the answer: point every run at one folder, download once. Read the fine print in Terraform/OpenTofu’s own docs, though: that cache directory is &lt;em&gt;not guaranteed to be concurrency safe&lt;/em&gt;, and the provider installer’s behaviour with multiple concurrent &lt;code&gt;init&lt;/code&gt; calls is undefined. Two runners populating the same cold cache at the same moment can meet a half-unpacked provider. Terragrunt’s own performance docs add the other half: at large scale, filesystem lock contention on that shared directory becomes the bottleneck. It works in a demo. CI is not a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: zip it, ship it, unzip it
&lt;/h2&gt;

&lt;p&gt;The thread’s S3 proof-of-concept failed for a boring reason: compression is a tax you pay twice — once packing, once unpacking — on every run, warm or cold. A cache that costs minutes to restore is just a slower download wearing a costume. Same family: pre-baking providers into the runner image trades download time for an image so large the runner spends its life pulling it instead. The thread’s ~80 GB estimate is their number, not a law of physics — but the direction is right: the image becomes the download.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhqkmmcc5o36eq2jxn86c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhqkmmcc5o36eq2jxn86c.png" alt="Options diagram: no cache, plugin cache dir alone, provider cache server, pre-baked image, S3 zip cache — with the catch on each." width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix Terragrunt already built: the Provider Cache Server
&lt;/h2&gt;

&lt;p&gt;Terragrunt ships a provider cache server made for exactly this shape — many units, parallel runs, one shared cache. Turn it on per run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terragrunt run &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--provider-cache&lt;/span&gt; plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or with environment variables, which is how you will actually do it in CI:&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;TG_PROVIDER_CACHE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;TG_PROVIDER_CACHE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/shared/terragrunt-provider-cache &lt;span class="se"&gt;\&lt;/span&gt;
terragrunt run &lt;span class="nt"&gt;--all&lt;/span&gt; plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What happens then: Terragrunt serves providers to its own Terraform/OpenTofu runs from the shared cache directory, downloading from the registry only on a genuine miss, and the cache directory uses the same layout as Terraform’s plugin cache — so an existing warmed cache folder can be reused by pointing &lt;code&gt;--provider-cache-dir&lt;/code&gt; (or &lt;code&gt;TG_PROVIDER_CACHE_DIR&lt;/code&gt;) at it. By default it caches providers from &lt;code&gt;registry.terraform.io&lt;/code&gt; and &lt;code&gt;registry.opentofu.org&lt;/code&gt;; private registries can be added with &lt;code&gt;--provider-cache-registry-names&lt;/code&gt; / &lt;code&gt;TG_PROVIDER_CACHE_REGISTRY_NAMES&lt;/code&gt;. One warning from experience-shaped common sense: the cache dir must be a filesystem your runners actually share (your EFS idea works for this), and it must survive between runs — a cache on an ephemeral runner disk that dies with the pod is a diary, not a cache.&lt;/p&gt;

&lt;p&gt;On newer OpenTofu (1.10 and later), Terragrunt also has an automatic provider cache dir mode that sets &lt;code&gt;TF_PLUGIN_CACHE_DIR&lt;/code&gt; to a central location for you, since that OpenTofu line handles concurrent cache access safely — Terragrunt’s docs position the cache server as the pick for Terraform and older OpenTofu, or when you need the advanced sharing. Know which of the two you turned on; running both by accident is how cache behaviour becomes folklore.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the modules?
&lt;/h2&gt;

&lt;p&gt;Providers are the heavy half, but do not ignore the other download: Terragrunt fetches each unit’s module source into its per-unit &lt;code&gt;.terragrunt-cache&lt;/code&gt;. Persisting that between CI runs helps, but key the cache on your lockfiles and config hashes, and prune it — Terragrunt and env0-style guidance both warn the cache directory grows large in CI if you never clean it. A cache with no eviction plan is a disk-full incident with a waiting period.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiboe1xfs0ilf5shuceho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiboe1xfs0ilf5shuceho.png" alt="Before and after diagram: every run downloads everything and a network blip fails the job, versus download once and reuse with registry fallback on a miss." width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI caching checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cache server on:&lt;/strong&gt; &lt;code&gt;TG_PROVIDER_CACHE=1&lt;/code&gt; set for Terragrunt runs in CI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Shared, persistent dir:&lt;/strong&gt; &lt;code&gt;TG_PROVIDER_CACHE_DIR&lt;/code&gt; points at storage that outlives the runner pod (shared volume, not pod disk).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Private registries listed:&lt;/strong&gt; any registry beyond the two defaults added via &lt;code&gt;TG_PROVIDER_CACHE_REGISTRY_NAMES&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No naked shared plugin cache under parallelism:&lt;/strong&gt; plain &lt;code&gt;TF_PLUGIN_CACHE_DIR&lt;/code&gt; sharing is not concurrency-guaranteed — that is the cache server’s job.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Module cache keyed + pruned:&lt;/strong&gt; &lt;code&gt;.terragrunt-cache&lt;/code&gt; persisted with a lockfile-based key and a size cap.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No zip archives:&lt;/strong&gt; if restoring the cache means decompressing it, you rebuilt the download with extra steps.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No provider-stuffed runner image:&lt;/strong&gt; the image stays small; the cache lives outside it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Staging warning:&lt;/strong&gt; test cache behaviour in staging first — a poisoned shared cache fails every pipeline at once.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The fix, in one line:&lt;/strong&gt; stop downloading providers per run and stop sharing the naive cache folder across parallel inits — switch on Terragrunt’s provider cache server, park its directory on storage that survives the runner, and let the registry become the fallback instead of the routine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;This post describes &lt;strong&gt;my personal experience&lt;/strong&gt; with my own systems and accounts, in my own words. It is a first-person account of what I observed and what I do in my own work — not a statement of any company’s intentions, policies, or internal decisions. Where I do not know something, the post says so.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I am &lt;strong&gt;not affiliated with, endorsed by, or speaking for&lt;/strong&gt; any company, product, or vendor named in this post. All product and company names are &lt;strong&gt;trademarks of their respective owners&lt;/strong&gt; and are mentioned only to describe the experience accurately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Any error messages, documentation excerpts, or figures quoted here are reproduced from my own records or from the public sources linked in the post, believed accurate at the time of writing. Product behavior changes over time; verify against current official documentation before relying on any detail.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Nothing in this post is &lt;strong&gt;professional, financial, legal, or security advice&lt;/strong&gt; for your specific situation. Examples are illustrative — validate in your own environment before running anything in production.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Corrections are welcome.&lt;/strong&gt; If any company named here publishes an official statement, or if any detail is wrong, I will update this post and note the correction. The goal is an accurate public record, not a verdict.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;© 2026 Honey Kiran. All rights reserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honey Kiran&lt;/strong&gt; · DevOps &amp;amp; Platform Engineering&lt;/p&gt;

&lt;p&gt;Writing on monitoring, SRE, Terraform, Kubernetes and platform engineering — one hard-won lesson at a time.&lt;/p&gt;

&lt;p&gt;Tell me in the replies: what does your CI re-download every run that it has no business downloading twice?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Examples are illustrative — validate in your own environment before running in production.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>terragrunt</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
  </channel>
</rss>
