<?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: softverseAdmin</title>
    <description>The latest articles on DEV Community by softverseAdmin (@softverseadmin).</description>
    <link>https://dev.to/softverseadmin</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%2F4217550%2Fc892b60b-191f-41b6-9516-5fd2465af4a2.png</url>
      <title>DEV Community: softverseAdmin</title>
      <link>https://dev.to/softverseadmin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/softverseadmin"/>
    <language>en</language>
    <item>
      <title>Restore Drills in 2026: You Can't Automate the Declaration, Only the Comparison</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Mon, 02 Nov 2026 06:10:10 +0000</pubDate>
      <link>https://dev.to/softverseadmin/restore-drills-in-2026-you-cant-automate-the-declaration-only-the-comparison-1nio</link>
      <guid>https://dev.to/softverseadmin/restore-drills-in-2026-you-cant-automate-the-declaration-only-the-comparison-1nio</guid>
      <description>&lt;h1&gt;
  
  
  Restore Drills in 2026: You Can't Automate the Declaration, Only the Comparison
&lt;/h1&gt;

&lt;p&gt;The previous post in this little series — &lt;a href="https://www.devopsenginer.com/blog/backup-verification-in-2026-exit-code-0-is-not-a-backup" rel="noopener noreferrer"&gt;Backup Verification in 2026: Exit Code 0 Is Not a Backup&lt;/a&gt; — grew out of a comment thread under a dev.to article by &lt;strong&gt;dafili&lt;/strong&gt;, &lt;a href="https://dev.to/dafili/my-backup-exited-0-for-three-weeks-and-nothing-paged-me-5ce6"&gt;"My Backup Exited 0 for Three Weeks and Nothing Paged Me"&lt;/a&gt;. The conversation did not stop at publication. Dafili corrected one claim in that post (the correction is in, and credited), and then pushed the whole idea one level deeper — to a place the four-level ladder does not reach on its own.&lt;/p&gt;

&lt;p&gt;His point, in his words: &lt;strong&gt;"You cannot automate the declaration, only the comparison."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is about what that sentence means, because once you hear it, a hole opens up in the middle of most restore drills — including the tidy ones from the ladder post. The ladder tells you &lt;em&gt;how&lt;/em&gt; to check a backup. It never tells you what "correct" means. And if the definition of correct comes from the same system you are checking, your drill can pass beautifully while verifying nothing at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A restore drill is only as honest as the expectations it compares against. If those expectations are derived from the backup or the system being verified — row counts read from the dump itself, file lists generated by the backup tool — the check is circular: it catches corruption, never wrongness. The fix is a &lt;strong&gt;fingerprint&lt;/strong&gt;: a short, hand-written declaration of what a correct restore must contain (an expected file list, one file's sha256, a row count, one config value). A human re-declares it quarterly; automation compares against it nightly and shouts on drift. And the drill itself gets monitored with a &lt;code&gt;days_since_last_successful_manual_restore&lt;/code&gt; gauge that pages at about 120 days — because an unmonitored drill quietly stops happening.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/restore-drills-in-2026-you-cant-automate-the-declaration" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder checks the climb, not the destination
&lt;/h2&gt;

&lt;p&gt;The four levels from the previous post — the artifact exists, it is intact, it can be read, it has actually been restored — still stand. Level 4 is where trust lives: restore into a scratch environment and verify the result like a user would.&lt;/p&gt;

&lt;p&gt;But look closely at how a typical level-4 drill "verifies." It restores the dump, then runs queries against the restored database: count the rows in &lt;code&gt;users&lt;/code&gt;, check that &lt;code&gt;orders&lt;/code&gt; has recent entries, confirm the application boots. Where do the expected values for those checks come from? Usually from the restored data itself, or from the same monitoring that watched the original system. The drill asks the backup: &lt;em&gt;do you agree with yourself?&lt;/em&gt; The backup always says yes.&lt;/p&gt;

&lt;p&gt;That is how a drill passes on data that is perfectly intact and completely wrong. Dafili made this distinction in the same thread: an integrity check catches media or repository corruption, but only a restore catches data that is wrong — and even then, only if the restore is compared against something that did not come from the restore. An empty mount backed up faithfully, checksummed faithfully, restored faithfully. A dump of the wrong database, in perfect condition. Every level green. Nothing recovered that you actually wanted.&lt;/p&gt;

&lt;p&gt;He explored the same trap from another angle in a follow-up article, &lt;a href="https://dev.to/dafili/three-checks-that-look-like-they-passed-but-validated-nothing-ejd"&gt;"Three checks that look like they passed but validated nothing"&lt;/a&gt;: checks that execute correctly, return success, and prove nothing, because they validate a different property than the one you care about. A restore drill with self-derived expectations is exactly that shape of check — bigger, more expensive, and just as capable of validating nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fingerprint: four things a human writes down
&lt;/h2&gt;

&lt;p&gt;Dafili's answer is a &lt;strong&gt;fingerprint&lt;/strong&gt; — a small file a human writes by hand, declaring what a correct restore must contain. Not generated. Not exported from the database. Declared, the way you would declare what matters before a trip: passport, keys, the one document you cannot replace. His fingerprint has four components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An expected file list.&lt;/strong&gt; The files (or tables, or object prefixes) that must be present in the restore. Not all of them — the ones whose absence would mean the backup missed its point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One file's sha256.&lt;/strong&gt; Pick one file whose content you can predict — a config file, a seed data file, a known document — and record its hash. The restore must reproduce it bit for bit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A row count.&lt;/strong&gt; One table that matters, with a count (or a sane range) a human states from knowing the business, not from querying the backup. Orders should be in the hundreds of thousands by now; if the restore says 41, something is deeply wrong even if the restore "worked."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One config value.&lt;/strong&gt; A single setting whose correctness you can declare: the application's environment name, a feature flag that must be on, the schema version the code expects. Config drift is how "successful" restores fail at boot time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four declarations. Small enough to write in fifteen minutes, small enough to read at a glance, and — this is the whole design — &lt;strong&gt;none of them can be produced by the system they verify&lt;/strong&gt;. The fingerprint stands outside the backup pipeline and judges it.&lt;/p&gt;

&lt;p&gt;An example, as a plain file kept with the runbook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# restore-fingerprint.yaml — declared by hand, re-declared quarterly&lt;/span&gt;
&lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing-db&lt;/span&gt;
&lt;span class="na"&gt;expected_files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/app/config/app.env&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/app/data/seed-countries.csv&lt;/span&gt;
&lt;span class="na"&gt;sha256&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;/srv/app/config/app.env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9f2c…e41a"&lt;/span&gt;   &lt;span class="c1"&gt;# recorded at declaration time&lt;/span&gt;
&lt;span class="na"&gt;row_counts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;orders&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;400000"&lt;/span&gt;                      &lt;span class="c1"&gt;# stated from the business, not the dump&lt;/span&gt;
&lt;span class="na"&gt;config_values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schema_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026.09"&lt;/span&gt;                &lt;span class="c1"&gt;# what the current code expects&lt;/span&gt;
&lt;span class="na"&gt;declared&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-11-02&lt;/span&gt;
&lt;span class="na"&gt;next_review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2027-02-02&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact format does not matter. What matters is the direction of the arrow: expectations flow from a human's knowledge &lt;em&gt;into&lt;/em&gt; the check, never from the checked system &lt;em&gt;outward&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  You cannot automate the declaration
&lt;/h2&gt;

&lt;p&gt;Why insist a human writes it? Because the declaration is not data entry — it is a review disguised as data entry. To write the fingerprint, someone has to sit down and answer questions that no monitoring dashboard asks: &lt;em&gt;What in this system would we actually miss? Which table is the business? Which config value, if wrong, turns a good restore into a bad morning?&lt;/em&gt; Those answers live in people's heads and in the incident history, not in the database.&lt;/p&gt;

&lt;p&gt;In dafili's practice, the human &lt;strong&gt;re-declares the fingerprint quarterly&lt;/strong&gt; — deliberately on the same cadence as the full restore drill. Systems drift legitimately over a quarter: new tables appear, row counts grow, config values change on purpose. The quarterly re-declaration is the moment a human looks at that drift on purpose, in calm conditions, and says: &lt;em&gt;yes, this is what correct looks like now.&lt;/em&gt; Skip it, and the fingerprint slowly becomes a declaration about a system that no longer exists — which is its own quiet failure mode, and exactly why the declaration cannot be a one-time setup task.&lt;/p&gt;

&lt;p&gt;This is also why automating the declaration defeats the purpose. A generator that rebuilds the fingerprint from production every night produces a fingerprint that is always exactly right and therefore says nothing: it re-declares whatever the system currently is, including its mistakes. The value of the fingerprint is precisely that it can &lt;em&gt;disagree&lt;/em&gt; with the system — and that a human chose the terms of the disagreement in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison is the automation's job
&lt;/h2&gt;

&lt;p&gt;Everything after the declaration should be boring, mechanical, and nightly. Restore the latest backup into a scratch environment, extract the actuals, and diff them against the fingerprint:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# compare-restore.sh — runs after the nightly restore-to-scratch&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0

&lt;span class="c"&gt;# 1. Expected files present?&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; f&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"/tmp/restore-test&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"MISSING: &lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;yq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.expected_files[]'&lt;/span&gt; restore-fingerprint.yaml&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 2. The declared hash matches?&lt;/span&gt;
&lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;yq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.sha256."/srv/app/config/app.env"'&lt;/span&gt; restore-fingerprint.yaml&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;  /tmp/restore-test/srv/app/config/app.env"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"HASH DRIFT: app.env"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 3. The declared row count is in range?&lt;/span&gt;
&lt;span class="nv"&gt;ORDERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;psql restore_test &lt;span class="nt"&gt;-tAc&lt;/span&gt; &lt;span class="s2"&gt;"SELECT count(*) FROM orders;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 400000 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ROW COUNT DRIFT: orders=&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 4. The declared config value survived?&lt;/span&gt;
psql restore_test &lt;span class="nt"&gt;-tAc&lt;/span&gt; &lt;span class="s2"&gt;"SELECT value FROM settings WHERE key='schema_version';"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qx&lt;/span&gt; &lt;span class="s2"&gt;"2026.09"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CONFIG DRIFT: schema_version"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$FAIL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The details will differ with your stack — the pattern is the point. Automation compares; it does not get an opinion. When the comparison fails, it &lt;strong&gt;shouts&lt;/strong&gt;: a failed fingerprint comparison is a page, not a log line, because it means one of two things, both important — the backup is wrong, or the world changed and nobody re-declared it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift is a conversation, not just an alarm
&lt;/h2&gt;

&lt;p&gt;That second case deserves respect rather than dread. Most fingerprint failures, in a healthy team, will be legitimate change: a migration added a table, the row count outgrew the declared range, a config value rolled forward. The correct response is not to silence the check but to &lt;strong&gt;re-declare deliberately&lt;/strong&gt; — a human reviews the drift, confirms it is intended, and updates the fingerprint as its own small, visible act.&lt;/p&gt;

&lt;p&gt;Contrast that with the alternative everyone actually lives with: expectations embedded in the check script, edited quietly at 2 a.m. to make the red go away, until the check asserts nothing. The fingerprint does not prevent someone from weakening a check. It makes the weakening a declared event, in a file with a date on it, that a second person can review. That is the difference between a system that learns and a system that merely quiets down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitor the drill itself
&lt;/h2&gt;

&lt;p&gt;There is one more layer, and it might be the most practical idea in the whole thread. Automated nightly comparisons are excellent — right up until the human parts of the practice quietly die. The quarterly manual restore is the first thing skipped in a busy quarter. Then it is skipped twice. Nothing alarms, because the automation is still green; the drill's &lt;em&gt;judgment&lt;/em&gt; — a person restoring by hand, noticing the weird thing no fingerprint anticipated — has been absent for a year and nobody knows.&lt;/p&gt;

&lt;p&gt;Dafili's countermeasure is to monitor the drill itself with a gauge: &lt;strong&gt;&lt;code&gt;days_since_last_successful_manual_restore&lt;/code&gt;&lt;/strong&gt;, and to page when it crosses about &lt;strong&gt;120 days&lt;/strong&gt;. Not a ticket. A page — the same severity as a failed backup, because that is what it is: past roughly one quarter, you no longer have evidence that a human can restore this system, only evidence that a script once could.&lt;/p&gt;

&lt;p&gt;In Prometheus terms it is a timestamp gauge the drill updates when — and only when — a manual restore passes its fingerprint comparison:&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;# Written by the drill checklist's final step, after a human-run&lt;/span&gt;
&lt;span class="c"&gt;# restore passes the fingerprint comparison:&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"last_successful_manual_restore_timestamp &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/lib/node_exporter/textfile/restore_drill.prom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Page when no successful manual restore in ~120 days
(time() - last_successful_manual_restore_timestamp) / 86400 &amp;gt; 120
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Readers of the ladder post will recognise the shape: it is the dead-man's-switch from that article, pointed at the humans. There, the monitored absence was &lt;em&gt;verification success&lt;/em&gt; — page when the checks stop succeeding. Here, the monitored absence is the &lt;em&gt;drill itself&lt;/em&gt; — page when the people stop practising. Automation watches the backups; the gauge watches the watchers' habits. A restore you have not performed in four months is a hypothesis, and the gauge exists to say so out loud while it is still cheap to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the fingerprint live?
&lt;/h2&gt;

&lt;p&gt;One honest open question, because it came up in the thread and I do not have dafili's answer yet: &lt;strong&gt;where should the fingerprint be stored?&lt;/strong&gt; I asked him directly in the conversation; while his reply is pending, the trade-offs are worth stating plainly, because the obvious answer has a thorn in it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;With the backup configuration&lt;/strong&gt; is convenient — the drill finds it without ceremony. But it risks the fingerprint drifting &lt;em&gt;with&lt;/em&gt; the thing it verifies: the same change that breaks the backup can quietly rewrite the expectations, the way a scale that recalibrates itself to whatever it weighs can never report a wrong weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In a separate repository, owned like a runbook,&lt;/strong&gt; keeps the declaration independent and reviewable — changes arrive as commits a second person can see. The cost is one more place to keep current, and a drill that must fetch its expectations from somewhere it trusts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On paper, or in the incident binder,&lt;/strong&gt; sounds like a joke until you remember what the fingerprint is for: it is the declaration that survives the system. Four values fit on an index card.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wherever it lives, the property to protect is independence: the fingerprint must not be writable by the pipeline it judges. When dafili answers, this section gets an update — his reasoning has improved this series twice already.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore-drill fingerprint checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A fingerprint file exists for each system that matters, written by hand — not generated from the system it verifies&lt;/li&gt;
&lt;li&gt;It declares all four: an expected file list, one file's sha256, one row count (or range), one config value&lt;/li&gt;
&lt;li&gt;A human re-declares it quarterly, on the same cadence as the full restore drill&lt;/li&gt;
&lt;li&gt;The nightly drill restores to scratch and compares actuals against the fingerprint automatically&lt;/li&gt;
&lt;li&gt;A failed comparison pages — and legitimate drift is resolved by deliberate re-declaration, never by editing the check to be quiet&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;days_since_last_successful_manual_restore&lt;/code&gt; gauge (or equivalent) exists, with a page at about 120 days&lt;/li&gt;
&lt;li&gt;The fingerprint is stored somewhere the backup pipeline itself cannot rewrite&lt;/li&gt;
&lt;li&gt;Expectations in older checks that were derived from the checked system have been replaced with declared ones&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Isn't this just the level-4 verification from the previous post?&lt;/strong&gt;&lt;br&gt;
It is level 4 done honestly. The ladder says &lt;em&gt;restore and verify&lt;/em&gt;; the fingerprint answers the question that post left open — verify &lt;em&gt;against what?&lt;/em&gt; If the answer is "against values taken from the restore itself," the verification is circular. Declared expectations are what break the circle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four checks seem too few. Shouldn't the fingerprint cover everything?&lt;/strong&gt;&lt;br&gt;
Coverage is the automation's job — checksums, listings, and sampled reads already sweep the whole artifact nightly. The fingerprint has a different job: to be an independent witness. A witness who testifies about everything testifies about nothing; four declarations a human can hold in their head, and re-declare with full attention each quarter, beat four hundred generated ones nobody reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the business can't state a row count in advance?&lt;/strong&gt;&lt;br&gt;
Declare a range or a floor, stated from business knowledge ("orders grow by roughly ten thousand a month; a restore below last quarter's count is wrong"). Precision is not the point — independence is. An expectation that is roughly right and genuinely external catches the catastrophic wrongness the drill exists to catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the gauge replace scheduling the quarterly drill?&lt;/strong&gt;&lt;br&gt;
No — it catches the schedule failing. Calendars get ignored under load; that is precisely the failure the gauge exists to surface. Schedule the drill &lt;em&gt;and&lt;/em&gt; monitor its successful completion, the same way you schedule backups &lt;em&gt;and&lt;/em&gt; monitor verification success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The ladder from the previous post — exists, intact, readable, restored — tells you how to check a backup. Dafili's fingerprint tells you what makes the checking mean something: expectations a human declared, standing outside the system, that the system cannot rewrite to flatter itself. &lt;em&gt;You cannot automate the declaration, only the comparison.&lt;/em&gt; So declare by hand, re-declare quarterly, compare nightly, shout on drift — and keep a gauge on the drill itself, paging at about 120 days, so the practice cannot die in silence.&lt;/p&gt;

&lt;p&gt;Credit where it is due: the fingerprint doctrine, the declaration/comparison line, and the drill gauge all come from &lt;strong&gt;dafili&lt;/strong&gt;, in the comment thread under &lt;a href="https://dev.to/dafili/my-backup-exited-0-for-three-weeks-and-nothing-paged-me-5ce6"&gt;"My Backup Exited 0 for Three Weeks and Nothing Paged Me"&lt;/a&gt; — the same thread that produced the ladder post and corrected it. His follow-up, &lt;a href="https://dev.to/dafili/three-checks-that-look-like-they-passed-but-validated-nothing-ejd"&gt;"Three checks that look like they passed but validated nothing"&lt;/a&gt;, is the natural next read. This series keeps getting better because a reader keeps arguing with it in public. Long may that continue.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>backup</category>
      <category>monitoring</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Backup Verification in 2026: Exit Code 0 Is Not a Backup</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sun, 01 Nov 2026 05:19:56 +0000</pubDate>
      <link>https://dev.to/softverseadmin/backup-verification-in-2026-exit-code-0-is-not-a-backup-2fd7</link>
      <guid>https://dev.to/softverseadmin/backup-verification-in-2026-exit-code-0-is-not-a-backup-2fd7</guid>
      <description>&lt;h1&gt;
  
  
  Backup Verification in 2026: Exit Code 0 Is Not a Backup
&lt;/h1&gt;

&lt;p&gt;A story made the rounds in a DevOps community recently, and it should make anyone who owns a cron job uncomfortable. An engineer checked on a backup script that had been running perfectly by every visible measure — green in the scheduler, exit code 0, night after night — and found it had been "succeeding" at backing up nothing at all for three weeks, ever since something upstream had quietly changed. No error. No page. Just three weeks of confidence with nothing underneath it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A backup job's exit code tells you the script ran, not that you have a backup. A backup is only trustworthy at four levels: (1) the artifact exists, (2) it is structurally intact, (3) it can be opened and read, and (4) it has actually been restored and the data verified. Most teams verify level 1 and stop. This guide walks a practical verification ladder — checksums, &lt;code&gt;restic check&lt;/code&gt;, &lt;code&gt;pg_restore --list&lt;/code&gt;, scheduled restore drills — plus the monitoring change that matters most: page on &lt;strong&gt;verification&lt;/strong&gt; failure, not job failure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/backup-verification-in-2026-exit-code-0-is-not-a-backup" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is deliberately not a Kubernetes backup guide — cluster tooling like Velero and etcd snapshots is covered in the Kubernetes Backup and Disaster Recovery post. This is about the layer underneath every stack: database dumps, file archives, and object-storage backups, and how to prove they work before the day you need them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why exit code 0 lies
&lt;/h2&gt;

&lt;p&gt;Exit code 0 means one narrow thing: the last command in your script did not report an error. It says nothing about what the script actually captured. Backup scripts fail silently in a handful of classic ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong or empty source paths.&lt;/strong&gt; A directory gets moved, a mount fails to come up, or a path is renamed upstream. The archiver dutifully compresses an empty directory and exits 0. Your backup is a perfectly valid archive of nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expired or rotated credentials.&lt;/strong&gt; The dump tool fails to authenticate and writes a 0-byte file — or the wrapper script swallows the error, logs a warning nobody reads, and still exits successfully because the &lt;em&gt;upload&lt;/em&gt; of that empty file worked fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent partial captures.&lt;/strong&gt; A database dump taken without the right flags can complete while missing schemas, extensions, or large objects. The file is large, recent, and wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern underneath all of these: the job was monitored, but the &lt;em&gt;backup&lt;/em&gt; was never verified. Those are different jobs, and they need different checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four levels of backup confidence
&lt;/h2&gt;

&lt;p&gt;A useful way to think about backup verification is as a ladder. Each level proves something the level below cannot:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 1 — The artifact exists.&lt;/strong&gt; A file or snapshot with the expected name appeared in the expected place, recently, with a plausible size. This is what most monitoring checks. It proves a process ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 2 — The artifact is structurally intact.&lt;/strong&gt; The archive is not truncated or bit-rotted. Checksums match; the backup tool's own integrity check confirms its internal structures — indexes, metadata, pack files — are consistent. This proves the bits survived storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 3 — The artifact can be opened and read.&lt;/strong&gt; The restore tool can actually parse the backup: list the table of contents of a dump, enumerate the files in an archive, read back a sample of the stored data. This proves the format is valid and the content is what you think it is — not an empty tree, not the wrong source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 4 — The artifact has been restored and the data verified.&lt;/strong&gt; You restored it into a real (throwaway) environment and checked the result: the database answers queries, row counts and key tables look right, application files open. This is the only level that proves you can recover. Everything below it is inference.&lt;/p&gt;

&lt;p&gt;Notice what the ladder implies: levels 1–3 are cheap and automatable on every backup. Level 4 is more expensive, so you schedule it — sampled nightly or weekly, full restores on a rotation. What you cannot do is pretend level 1 is level 4. That gap is exactly where the three-weeks-of-nothing story lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verification ladder in practice
&lt;/h2&gt;

&lt;p&gt;Here is how to test yours, level by level. The examples use common tools — &lt;code&gt;restic&lt;/code&gt;, PostgreSQL dumps, plain tarballs in object storage — but the pattern transfers to whatever you run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 1: existence, freshness, and size sanity
&lt;/h3&gt;

&lt;p&gt;Check that the newest artifact exists, is younger than your backup interval (plus slack), and is within a sane size band of its predecessors. A backup that suddenly shrinks by 90% is a failed backup wearing a success costume.&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;# Fail loudly if no backup newer than 26 hours exists in the bucket&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://my-backups/postgres/ &lt;span class="nt"&gt;--recursive&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1

&lt;span class="c"&gt;# Size sanity: compare today's artifact against the 7-day median&lt;/span&gt;
&lt;span class="c"&gt;# and alert if it deviates by more than ~50%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tools like restic make this easier because the repository keeps snapshot metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic snapshots &lt;span class="nt"&gt;--last&lt;/span&gt; 1 &lt;span class="nt"&gt;--json&lt;/span&gt;   &lt;span class="c"&gt;# newest snapshot time, paths, tags&lt;/span&gt;
restic stats                       &lt;span class="c"&gt;# repository size trend&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Encode the rule: &lt;strong&gt;a missing, stale, or suddenly-tiny backup is a verification failure&lt;/strong&gt;, even if the job that produced it exited 0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 2: structural integrity with checksums and tool-native checks
&lt;/h3&gt;

&lt;p&gt;If you produce plain archives, generate a checksum at backup time and verify it at check time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; postgres-2026-11-01.dump.sha256
&lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; nightly-files.tar.gz   &lt;span class="c"&gt;# quick structural test for gzip streams&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;restic has integrity checking built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;restic check&lt;/code&gt; verifies the repository structure — all packs, indexes, and metadata present and consistent. It is fast and belongs in the nightly job. What it does &lt;em&gt;not&lt;/em&gt; do by default is re-read your backup data — that is level 3.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 3: prove it can be read back
&lt;/h3&gt;

&lt;p&gt;For archives, listing the contents is the minimum viable read test — it forces the tool to walk the whole structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-tzf&lt;/span&gt; nightly-files.tar.gz &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null   &lt;span class="c"&gt;# fails on truncation/corruption&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For PostgreSQL custom-format dumps, &lt;code&gt;pg_restore --list&lt;/code&gt; parses the dump's table of contents without restoring anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_restore &lt;span class="nt"&gt;--list&lt;/span&gt; postgres-2026-11-01.dump | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scan that listing for what &lt;em&gt;should&lt;/em&gt; be there: your critical tables, extensions, and the expected object count. An empty dump has a valid header and almost nothing under it — this is the check that catches the "backed up nothing" failure mode.&lt;/p&gt;

&lt;p&gt;For restic, the read-back check is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic check &lt;span class="nt"&gt;--read-data-subset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Understand the caveat before you rely on it: &lt;code&gt;--read-data-subset=5%&lt;/code&gt; reads and verifies &lt;strong&gt;5% of the data packs&lt;/strong&gt;, not 5% of each file. It is sampling. A corrupt pack outside the sample will not be found tonight. Two practices make sampling honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rotate with the n/t form, not the percentage.&lt;/strong&gt; &lt;code&gt;--read-data-subset=5%&lt;/code&gt; picks a &lt;em&gt;random&lt;/em&gt; subset on every run, so runs can overlap and some packs may never get sampled at all. The &lt;code&gt;n/t&lt;/code&gt; form is the deterministic one: restic splits the packs into t groups and checks group n. Pin it to the calendar — &lt;code&gt;--read-data-subset=$(date +%u)/7&lt;/code&gt; checks a different seventh each night and guarantees every pack is read within a week, for the same total I/O as one full check spread out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a full read periodically.&lt;/strong&gt; &lt;code&gt;restic check --read-data&lt;/code&gt; (100%) on a monthly cadence closes any remaining gaps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also add a content spot-check at this level: restore a handful of known files to a temp directory and diff them, or query the snapshot for a file you know changed yesterday. Existence of &lt;em&gt;expected content&lt;/em&gt; is a much stronger signal than existence of &lt;em&gt;an artifact&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 4: restore drills — the only level that counts
&lt;/h3&gt;

&lt;p&gt;Restore into a throwaway environment and verify the result like a user would. For PostgreSQL:&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;# Restore last night's dump into a scratch database&lt;/span&gt;
createdb &lt;span class="nt"&gt;-T&lt;/span&gt; template0 restore_test
pg_restore &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;restore_test postgres-2026-11-01.dump

&lt;span class="c"&gt;# Then verify like the application would&lt;/span&gt;
psql restore_test &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT count(*) FROM users;"&lt;/span&gt;
psql restore_test &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT max(created_at) FROM orders;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Row counts in a plausible range and a recent &lt;code&gt;max(created_at)&lt;/code&gt; on your busiest table prove the restore contains live, current data — not a skeleton schema and not last month's state.&lt;/p&gt;

&lt;p&gt;For file backups, restore the latest snapshot to a scratch path and verify file counts and a sample of checksums against expectations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic restore latest &lt;span class="nt"&gt;--target&lt;/span&gt; /tmp/restore-test
find /tmp/restore-test &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The drill is not done when the restore command exits 0. The drill is done when a check &lt;em&gt;of the restored data&lt;/em&gt; passes. And time the drill: the duration you measure is your real recovery time, not the one in the runbook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make verification a separate job that can fail
&lt;/h2&gt;

&lt;p&gt;The single most important structural change: &lt;strong&gt;verification must be its own job, with its own failure state, that runs after the backup job.&lt;/strong&gt; Folded into the backup script, a verification failure disappears into the same green checkmark. Give it its own exit code and its own alert path.&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# verify-backups.sh — runs after the backup job, fails independently&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;LATEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;restic snapshots &lt;span class="nt"&gt;--last&lt;/span&gt; 1 &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[0].id'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

restic check                                   &lt;span class="c"&gt;# level 2&lt;/span&gt;
restic check &lt;span class="nt"&gt;--read-data-subset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5%             &lt;span class="c"&gt;# level 3 (sampled)&lt;/span&gt;
restic restore &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LATEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--target&lt;/span&gt; /tmp/restore-test &lt;span class="nt"&gt;--include&lt;/span&gt; /srv/app/config
&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /tmp/restore-test/srv/app/config/app.env   &lt;span class="c"&gt;# expected content present&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"backup_verification_success &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/lib/node_exporter/textfile/backup_verification.prom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script writes a &lt;strong&gt;success timestamp&lt;/strong&gt; only if every level passed — and that timestamp is what you monitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitor the verification, not the job
&lt;/h2&gt;

&lt;p&gt;Alerting on "backup job failed" catches crashes. It does not catch the failure mode this article is about, because in that failure mode the job &lt;em&gt;succeeds&lt;/em&gt;. Change what you page on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Page on verification failure.&lt;/strong&gt; Any level failing — stale artifact, size anomaly, checksum mismatch, unreadable dump, failed drill — is a page-worthy event, because it means you currently do not have a proven backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page on missing success.&lt;/strong&gt; Track "time since last successful &lt;em&gt;verification&lt;/em&gt;" and alert when it exceeds your threshold (for a nightly cycle, ~30 hours). This dead-man's-switch pattern catches the silent cases: the verification job itself dying, the scheduler stopping, credentials expiring before the check even runs. Absence of failure is not presence of success — so monitor the presence of success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track your verified restore point.&lt;/strong&gt; The metric that matters in an incident is "how old is the newest backup that has passed level 4?" If that number quietly grows to three weeks, you want to know before the incident, not during it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Prometheus, the textfile pattern above plus one alert — &lt;code&gt;time() - backup_verification_success &amp;gt; 30 * 3600&lt;/code&gt; — covers most of this. The tooling is not the hard part; deciding that verification failure pages like a production outage is.&lt;/p&gt;

&lt;h2&gt;
  
  
  A restore-drill cadence you can actually keep
&lt;/h2&gt;

&lt;p&gt;Verification fails as a practice when it depends on someone remembering. Put it on a calendar:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cadence&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Levels&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nightly (automated)&lt;/td&gt;
&lt;td&gt;Every backup, right after it lands&lt;/td&gt;
&lt;td&gt;1–3, with n/t-rotated read-data subsets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly (automated)&lt;/td&gt;
&lt;td&gt;Partial level-4: restore one database or one snapshot subset to a scratch environment; run content checks&lt;/td&gt;
&lt;td&gt;4 (partial)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly (automated + reviewed)&lt;/td&gt;
&lt;td&gt;Full restore of &lt;strong&gt;one&lt;/strong&gt; production system on rotation, timed end to end&lt;/td&gt;
&lt;td&gt;4 (full)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quarterly&lt;/td&gt;
&lt;td&gt;Full restore drill treated like a game day: restore, verify, record duration and surprises, update the runbook&lt;/td&gt;
&lt;td&gt;4 (full, measured)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rotation matters: every system should get a full restore at least once a quarter, rather than an annual hero-drill on the one system everybody already understands. Record each drill's duration — that number is your measured RTO, and it is almost always worse than the assumed one. Finding that out in a drill is the entire point.&lt;/p&gt;

&lt;p&gt;Cloud snapshots deserve a specific warning here: a snapshot of a disk or database is level 1 by default. Snapshots faithfully preserve corruption, they can be silently incomplete if taken mid-write without coordination, and "we have snapshots" has ended more than one incident review badly. Snapshots need the same ladder — restore one into an isolated instance, open it, query it — before they count as backups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backup verification checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every backup has a &lt;strong&gt;separate verification job&lt;/strong&gt; with its own pass/fail, running after the backup completes&lt;/li&gt;
&lt;li&gt;Level 1: freshness and size-band checks on every artifact, every cycle&lt;/li&gt;
&lt;li&gt;Level 2: checksums verified for plain archives; &lt;code&gt;restic check&lt;/code&gt; (or your tool's equivalent) nightly&lt;/li&gt;
&lt;li&gt;Level 3: read-back tests — &lt;code&gt;pg_restore --list&lt;/code&gt;, &lt;code&gt;tar -t&lt;/code&gt;, &lt;code&gt;restic check --read-data-subset&lt;/code&gt; rotated with the &lt;strong&gt;n/t&lt;/strong&gt; form, plus expected-content spot checks&lt;/li&gt;
&lt;li&gt;Level 4: automated partial restores weekly; full restores monthly on rotation; quarterly timed drill&lt;/li&gt;
&lt;li&gt;Monitoring pages on &lt;strong&gt;verification failure&lt;/strong&gt; and on &lt;strong&gt;missing verification success&lt;/strong&gt; (~30h for nightly cycles)&lt;/li&gt;
&lt;li&gt;"Age of newest level-4-verified backup" is a visible metric, not a surprise&lt;/li&gt;
&lt;li&gt;Restore durations are recorded, so your RTO is measured rather than assumed&lt;/li&gt;
&lt;li&gt;Snapshots are restore-tested like any other backup before being trusted&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;restic check&lt;/code&gt; on its own enough?&lt;/strong&gt;&lt;br&gt;
It covers level 2 well and, with &lt;code&gt;--read-data-subset&lt;/code&gt;, samples level 3. It never restores anything, so it cannot prove level 4. Treat it as the nightly baseline, not the whole strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doesn't all this verification double my costs?&lt;/strong&gt;&lt;br&gt;
Levels 1–3 are metadata, checksums, listings, and sampled reads — cheap. Level 4 restores cost scratch compute for a few hours a month. Compare that with the cost of discovering, mid-incident, that the backups were empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My backups are managed by my cloud provider. Am I covered?&lt;/strong&gt;&lt;br&gt;
Managed backup services still produce artifacts that can be empty, misconfigured, or pointed at the wrong source. The provider runs the job; verification that the artifact restores &lt;em&gt;your&lt;/em&gt; data is still yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the one change to make first?&lt;/strong&gt;&lt;br&gt;
Add the dead-man's-switch alert on "time since last successful verification." It is a small change, and it converts the worst failure mode — silent, indefinite, total — into a page within a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;An exit code tells you a script ran. A trustworthy backup has been checked at four levels: it exists, it is intact, it can be read, and it has been restored with the data verified. Automate the first three nightly; schedule the fourth — weekly partials, monthly full restores on rotation, quarterly timed drills. And page when &lt;em&gt;verification&lt;/em&gt; fails or goes missing, not merely when a job crashes. The engineer in the opening story had monitoring. What they did not have was a single check that asked the only question that matters: &lt;em&gt;if I needed this backup tonight, could I restore it?&lt;/em&gt; Build the checks that answer that question while the answer is still cheap.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>backup</category>
      <category>monitoring</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Prometheus in 2026: Metrics, PromQL and Alerting That Pages Only When It Matters</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:58:56 +0000</pubDate>
      <link>https://dev.to/softverseadmin/prometheus-in-2026-metrics-promql-and-alerting-that-pages-only-when-it-matters-p7a</link>
      <guid>https://dev.to/softverseadmin/prometheus-in-2026-metrics-promql-and-alerting-that-pages-only-when-it-matters-p7a</guid>
      <description>&lt;h1&gt;
  
  
  Prometheus in 2026: Metrics, PromQL and Alerting That Pages Only When It Matters
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Prometheus fails teams in two predictable ways: cardinality explosions from unbounded labels, and alert noise that trains everyone to ignore pages. The fixes are disciplines, not features. Model metrics as counters, gauges, and histograms with small, bounded label sets; measure services with RED (rate, errors, duration) and resources with USE (utilisation, saturation, errors); compute ratios and quantiles with recording rules so dashboards and alerts stay fast; and alert on user-visible symptoms with an action attached — via Alertmanager grouping and inhibition — never on every cause you can measure. Keep Prometheus itself for what it is (superb short-to-medium-term metrics), and remote-write to long-term storage when history matters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/prometheus-monitoring-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The model in one picture
&lt;/h2&gt;

&lt;p&gt;Prometheus &lt;strong&gt;pulls&lt;/strong&gt;: on an interval, it scrapes an HTTP endpoint (conventionally &lt;code&gt;/metrics&lt;/code&gt;) on each target, parses the exposition format, and stores samples in a time-series database. A &lt;strong&gt;time series&lt;/strong&gt; is a metric name plus a set of labels — &lt;code&gt;http_requests_total{job="api", status="200"}&lt;/code&gt; — and each unique combination is a separate series with its own storage and query cost. That one sentence explains almost every Prometheus behaviour and failure mode in this guide.&lt;/p&gt;

&lt;p&gt;Three metric types do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Counters&lt;/strong&gt; only increase (requests served, errors seen). You almost never graph a counter raw; you graph its &lt;code&gt;rate()&lt;/code&gt; — which also handles counter resets when a process restarts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gauges&lt;/strong&gt; go up and down (queue depth, memory in use, in-flight requests). Current value, min/max/avg over a window — no rate needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Histograms&lt;/strong&gt; count observations into buckets (&lt;code&gt;le&lt;/code&gt; labels), which is what makes server-side quantiles possible: &lt;code&gt;histogram_quantile()&lt;/code&gt; aggregates across instances, which summary-type metrics cannot do correctly. Choose bucket boundaries around the latencies you actually care about — your SLO threshold must live inside your buckets to be measurable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to measure: RED and USE
&lt;/h2&gt;

&lt;p&gt;Instrumentation fails by excess more often than by absence. Two checklists keep it purposeful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RED for every service and endpoint that matters:&lt;/strong&gt; request &lt;strong&gt;Rate&lt;/strong&gt;, &lt;strong&gt;Errors&lt;/strong&gt; (ratio, not just count), &lt;strong&gt;Duration&lt;/strong&gt; (histogram). This is the user-visible truth of a service, and the raw material of every good alert below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;USE for every resource:&lt;/strong&gt; &lt;strong&gt;Utilisation&lt;/strong&gt; (busy fraction), &lt;strong&gt;Saturation&lt;/strong&gt; (queued work — the earliest warning), &lt;strong&gt;Errors&lt;/strong&gt;. CPU steal aside, saturation metrics are the ones that predict incidents instead of narrating them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else — business metrics, per-feature counters, debug detail — is optional gravy. High-cardinality detail belongs in &lt;strong&gt;logs&lt;/strong&gt; (the logging guide's correlation IDs are exactly for this), not in labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cardinality: the budget everyone blows
&lt;/h2&gt;

&lt;p&gt;Series count is the product of label-value combinations. A metric with 10 endpoints × 5 status codes × 4 instances is 200 series — fine. Add a &lt;code&gt;user_id&lt;/code&gt; label and it becomes millions — the database slows, memory climbs, queries time out, and the postmortem writes itself. The rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Labels identify dimensions with few, bounded values&lt;/strong&gt;: endpoint (normalised — &lt;code&gt;/users/123&lt;/code&gt; becomes &lt;code&gt;/users/:id&lt;/code&gt; before it is a label), status class or code, method, instance, zone, version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never label with unbounded values&lt;/strong&gt;: user IDs, request IDs, session tokens, full URLs, error message text. Each is a series factory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrape-time relabeling is the safety net&lt;/strong&gt;: Prometheus can drop or rewrite label values during scraping — the place to strip an offending label from a third-party exporter you cannot change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the budget&lt;/strong&gt;: the targets and metrics with the highest series counts should be known numbers in your stack, reviewed like cloud spend (the Kubernetes cost guide makes the same argument for compute).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  PromQL that earns its keep
&lt;/h2&gt;

&lt;p&gt;Four patterns cover most production questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error ratio&lt;/strong&gt; (the alert-worthy form of errors):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(rate(http_requests_total{job="api", status=~"5.."}[5m]))
/
sum(rate(http_requests_total{job="api"}[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Latency quantile from a histogram:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;histogram_quantile(0.95,
  sum by (le) (rate(http_request_duration_seconds_bucket{job="api"}[10m])))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Saturation&lt;/strong&gt; — in-flight work versus capacity is usually a gauge ratio; the shape matters more than the exact metric names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence detection&lt;/strong&gt; — &lt;code&gt;absent()&lt;/code&gt; catches the series that &lt;em&gt;stopped&lt;/em&gt;, which is how dead exporters and vanished jobs announce themselves; pair it with a dead man's switch on the alerting pipeline itself, so "no alerts" can be distinguished from "alerting is broken."&lt;/p&gt;

&lt;p&gt;Two habits separate useful PromQL from fragile PromQL: always &lt;code&gt;rate()&lt;/code&gt; counters (never subtract samples by hand), and aggregate with &lt;code&gt;sum by (...)&lt;/code&gt; naming exactly the labels you intend to keep — a query that silently sums across a label you cared about produces confident, wrong dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recording rules: pay for queries once
&lt;/h2&gt;

&lt;p&gt;Dashboards and alerts re-run the same expressions relentlessly. &lt;strong&gt;Recording rules&lt;/strong&gt; precompute them into new series on an interval:&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;groups&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-slo&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job:http_error_ratio:5m&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;sum(rate(http_requests_total{job="api", status=~"5.."}[5m]))&lt;/span&gt;
          &lt;span class="s"&gt;/&lt;/span&gt;
          &lt;span class="s"&gt;sum(rate(http_requests_total{job="api"}[5m]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the alert is trivial, fast, and testable against the recorded series:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ApiHighErrorRate&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job:http_error_ratio:5m &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;0.05&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&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;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;page&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5xx&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ratio&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;above&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;minutes"&lt;/span&gt;
          &lt;span class="na"&gt;runbook_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://wiki.example.com/runbooks/api-errors"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the anatomy of a good alert: a symptom (error &lt;em&gt;ratio&lt;/em&gt;, sustained), a &lt;code&gt;for&lt;/code&gt; duration that ignores blips, a severity, and a &lt;strong&gt;runbook link&lt;/strong&gt;. An alert without a next action is a notification, not an alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alerting philosophy: symptoms page, causes ticket
&lt;/h2&gt;

&lt;p&gt;Alert fatigue is an availability risk in its own right — a team paged twenty times a night stops reading page one. The discipline that fixes it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Page on symptoms users feel&lt;/strong&gt;: error ratio, latency burn, saturation of the user path. These have a natural threshold — the SLO — and a natural owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ticket (or dashboard) the causes&lt;/strong&gt;: disk filling, a replica down, a certificate ageing. Important, rarely 3 a.m.-shaped; a Deployment with one of three replicas down is healthy by design and should page nobody.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Burn-rate thinking&lt;/strong&gt;: rather than a single static threshold, alert on how fast the error budget is being consumed (fast burn pages, slow burn tickets). Recording rules make multi-window burn alerts practical; the concept matters more than any specific window pair.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alertmanager does the traffic control&lt;/strong&gt;: &lt;strong&gt;grouping&lt;/strong&gt; collapses a hundred instance alerts from one outage into one notification; &lt;strong&gt;inhibition&lt;/strong&gt; suppresses downstream symptom alerts while a known root alert fires; &lt;strong&gt;silences&lt;/strong&gt; mute deliberately during maintenance; &lt;strong&gt;routing&lt;/strong&gt; sends each alert to the team that owns the action. Configure these on purpose — default Alertmanager behaviour is a starting point, not a design.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture and storage honesty
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local retention is short by design.&lt;/strong&gt; A Prometheus server's local TSDB commonly retains around 15 days by default; that is a feature of its role — fast, recent, operational metrics — not a limitation to apologise for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-term history goes elsewhere&lt;/strong&gt;: remote-write to Thanos, Grafana Mimir, or VictoriaMetrics (the common open options) when you need months of data for capacity and trend work. Keep the operational Prometheus queryable even if the long-term tier is down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Federation is rarely the answer&lt;/strong&gt; it first appears to be; hierarchical scraping duplicates data and queries painfully. Prefer remote-write and per-team/per-cluster servers with a global query layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In Kubernetes&lt;/strong&gt;, the Prometheus Operator's &lt;code&gt;ServiceMonitor&lt;/code&gt;/&lt;code&gt;PodMonitor&lt;/code&gt; resources turn scrape config into the same GitOps-reviewed YAML as everything else in this series (the observability guide covers the wider stack); &lt;code&gt;kube-state-metrics&lt;/code&gt; supplies object state (pending pods, failed jobs, replica mismatches) and node exporter supplies host USE metrics. Watch the operator's own cardinality — Kubernetes state is prolific.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short-lived jobs&lt;/strong&gt; can't be scraped on an interval; the Pushgateway exists for them, with real caveats: it doesn't expire series for you, and pushed metrics lose the scrape's health signal. Prefer recording outcomes from the platform side (the Jobs guide's failure conditions) or pushing sparingly and deliberately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unbounded labels&lt;/strong&gt;, the cardinal sin above — most often a well-meant &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;, or &lt;code&gt;error&lt;/code&gt; label that turns a metric into a million series over a weekend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alerting on causes.&lt;/strong&gt; CPU at 80% pages; the error budget untouched. Pages spent on non-symptoms are how real symptoms get muted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counters graphed raw&lt;/strong&gt; — ever-climbing lines that say nothing; or worse, alerts on them. &lt;code&gt;rate()&lt;/code&gt; exists; use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Averaging quantiles.&lt;/strong&gt; Averaging per-instance p95s produces a number that is no instance's p95 and nobody's truth. Histograms plus &lt;code&gt;histogram_quantile&lt;/code&gt; over the aggregate is the correct shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No recording rules on heavy dashboards&lt;/strong&gt; — the same 30-day, high-cardinality query from every open browser tab, grinding the server that is supposed to diagnose the incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating Prometheus as the archive.&lt;/strong&gt; Local retention quietly rotates; the quarter's capacity review finds 15 days of data. Decide the retention tiers like log retention (logging guide): hot and short in Prometheus, long in the remote tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent alerting pipeline.&lt;/strong&gt; Alertmanager down, webhook expired, routing typo — discovered during the incident it existed to announce. A dead man's switch (an always-firing alert whose &lt;em&gt;absence&lt;/em&gt; pages) is the standard guard.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;Prometheus is the metrics pillar of the observability guide's triad: metrics say &lt;em&gt;what and how much&lt;/em&gt;, logs say &lt;em&gt;what exactly&lt;/em&gt;, traces say &lt;em&gt;where in the path&lt;/em&gt;. The discipline rhymes across all three — bounded cardinality here, volume control in logging, sampling in tracing — because all three are the same budget wearing different clothes: keep the signal, shed the noise, and make sure the alerting end of the pipeline deserves the trust the team puts in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prometheus or Grafana — do I need both?&lt;/strong&gt;&lt;br&gt;
They are different layers: Prometheus collects and stores metrics and evaluates alerts; Grafana visualises (Prometheus has a basic built-in UI for queries, not dashboards). Most stacks run Prometheus (+ Alertmanager) for data and alerting, Grafana for dashboards — Grafana also fronts logs and traces, which is where the triad becomes one investigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pull or push — which is right?&lt;/strong&gt;&lt;br&gt;
Pull, by default: it gives Prometheus target health for free (&lt;code&gt;up&lt;/code&gt; series), centralises interval control, and survives target restarts gracefully. Push (Pushgateway) is the exception for genuinely short-lived batch work, used with its caveats in mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long should Prometheus retain data?&lt;/strong&gt;&lt;br&gt;
Operationally, two to four weeks local is common and usually plenty for incident work. Anything longer belongs in a remote-write long-term tier, where storage is cheaper and the queries are trend-shaped rather than incident-shaped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prometheus or OpenTelemetry?&lt;/strong&gt;&lt;br&gt;
Complementary, not rivals. OpenTelemetry is instrumentation and pipelines (APIs, SDKs, the Collector); Prometheus is a metrics backend with its own exposition format, which OTel can produce and feed (directly or via remote-write-compatible paths). Many stacks instrument with OTel and still query with PromQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When have I outgrown a single Prometheus?&lt;/strong&gt;&lt;br&gt;
When scrape volume, cardinality, or query load makes the single server the bottleneck — symptoms are scrape duration creeping toward the interval, slow range queries, and memory pressure dominated by series churn. The step is sharding by team/cluster plus a remote-write tier, not a bigger instance forever.&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>monitoring</category>
      <category>observability</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kubernetes Jobs and CronJobs in 2026: Batch Work That Finishes (or Fails) Cleanly</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:48:58 +0000</pubDate>
      <link>https://dev.to/softverseadmin/kubernetes-jobs-and-cronjobs-in-2026-batch-work-that-finishes-or-fails-cleanly-4ad2</link>
      <guid>https://dev.to/softverseadmin/kubernetes-jobs-and-cronjobs-in-2026-batch-work-that-finishes-or-fails-cleanly-4ad2</guid>
      <description>&lt;h1&gt;
  
  
  Kubernetes Jobs and CronJobs in 2026: Batch Work That Finishes (or Fails) Cleanly
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Deployments run forever; Jobs run to completion — and that one difference changes how you configure everything. A production Job needs an explicit contract: &lt;code&gt;restartPolicy: Never&lt;/code&gt; (or OnFailure, knowingly), a &lt;code&gt;backoffLimit&lt;/code&gt; you chose, an &lt;code&gt;activeDeadlineSeconds&lt;/code&gt; so nothing runs forever, and &lt;code&gt;ttlSecondsAfterFinished&lt;/code&gt; so finished work cleans itself up. CronJobs add three decisions on top: a &lt;code&gt;timeZone&lt;/code&gt;, a &lt;code&gt;concurrencyPolicy&lt;/code&gt; that matches whether runs may overlap, and a &lt;code&gt;startingDeadlineSeconds&lt;/code&gt; so a controller outage doesn't fire a pile of missed runs at once. Batch failures are normal — design for retries that are idempotent, and alert on failure rather than discovering it in the data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/kubernetes-jobs-cronjobs-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The workload Deployments can't express
&lt;/h2&gt;

&lt;p&gt;Everything earlier in this series assumes services: replicas that should always exist, replaced when they die. Batch work inverts the assumption — a migration, a report, a backup, an import has a &lt;em&gt;done&lt;/em&gt;, and success means the pods stop existing. Running batch work in a Deployment produces the classic pathologies: a completed container restarted forever, a failing task CrashLooping with no concept of "give up," no record of whether the run succeeded.&lt;/p&gt;

&lt;p&gt;The Job controller exists to own that lifecycle: create pods, watch them finish, count successes against &lt;code&gt;completions&lt;/code&gt;, count failures against &lt;code&gt;backoffLimit&lt;/code&gt;, and stop. CronJob is a Job factory on a schedule — nothing more mystical than that, which is the right way to reason about both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Job contract, field by field
&lt;/h2&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;batch/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;Job&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;nightly-export&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;completions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;parallelism&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;backoffLimit&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;activeDeadlineSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt;
  &lt;span class="na"&gt;ttlSecondsAfterFinished&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&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;export&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/export:1.4.2&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--date"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yesterday"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fields that decide behaviour:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;restartPolicy: Never&lt;/code&gt; (or &lt;code&gt;OnFailure&lt;/code&gt;)&lt;/strong&gt; — Jobs require one of these two; &lt;code&gt;Always&lt;/code&gt; is rejected. The choice matters: with &lt;strong&gt;Never&lt;/strong&gt;, a failed container means the pod fails and the controller creates a &lt;em&gt;replacement pod&lt;/em&gt; (visible, countable, each with its own logs). With &lt;strong&gt;OnFailure&lt;/strong&gt;, the kubelet restarts the container &lt;em&gt;inside the same pod&lt;/em&gt; — fewer pod objects, murkier failure history. Default to Never unless you have a reason; it makes failures legible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;backoffLimit&lt;/code&gt; (default 6)&lt;/strong&gt; — how many pod failures before the Job itself is marked Failed. Each retry waits with exponential backoff (10s, 20s, 40s… capped at 6 minutes). Six silent retries of a broken image is how a bad deploy burns an hour unnoticed; for most batch work 2–4 is a saner, louder choice. Finer control exists via &lt;strong&gt;pod failure policies&lt;/strong&gt; — rules that fail the Job immediately on specific exit codes (a config error will never fix itself) while ignoring disruptions like node drains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;activeDeadlineSeconds&lt;/code&gt;&lt;/strong&gt; — the Job's total time budget; exceed it and the Job fails and its pods are terminated. This is the field that prevents the "export job from March is still running" class of surprise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ttlSecondsAfterFinished&lt;/code&gt;&lt;/strong&gt; — after the Job completes or fails, wait this long, then delete the Job and its pods. Without it, finished Jobs and their pod corpses accumulate forever (and with them, cluttered &lt;code&gt;kubectl get pods&lt;/code&gt; output and etcd weight). A day is a reasonable default window for debugging; the logging pipeline from the logging guide is what preserves evidence beyond it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;completions&lt;/code&gt; / &lt;code&gt;parallelism&lt;/code&gt;&lt;/strong&gt; — for work queues: &lt;code&gt;completions&lt;/code&gt; is the total successes required, &lt;code&gt;parallelism&lt;/code&gt; the pod ceiling. Leave both unset for the simple one-shot case (one success, one pod). &lt;strong&gt;Indexed Jobs&lt;/strong&gt; (&lt;code&gt;completionMode: Indexed&lt;/code&gt;) give each pod a stable index (&lt;code&gt;JOB_COMPLETION_INDEX&lt;/code&gt;) for sharded work — partition the input by index instead of running a separate queue service.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Idempotency: the requirement retries impose
&lt;/h2&gt;

&lt;p&gt;A retried Job runs your code again against a world the first attempt may have half-changed. Batch code must therefore be &lt;strong&gt;idempotent or transactional&lt;/strong&gt;: write to a staging key and publish atomically, upsert rather than insert, name outputs deterministically (by date/partition), and check-before-create where the sink allows. The failure this prevents is specific and common: attempt one writes 90% of the export and dies; attempt two appends a second 100%; downstream reads 190% and nobody knows which records are real. Retries are the controller doing its job — making them safe is yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  CronJobs: a schedule plus three decisions
&lt;/h2&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;batch/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;CronJob&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;nightly-export&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;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Tokyo"&lt;/span&gt;
  &lt;span class="na"&gt;concurrencyPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Forbid&lt;/span&gt;
  &lt;span class="na"&gt;startingDeadlineSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;900&lt;/span&gt;
  &lt;span class="na"&gt;successfulJobsHistoryLimit&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;failedJobsHistoryLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;jobTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;backoffLimit&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;activeDeadlineSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt;
      &lt;span class="na"&gt;ttlSecondsAfterFinished&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&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;export&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/export:1.4.2&lt;/span&gt;
              &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--date"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yesterday"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;timeZone&lt;/code&gt;&lt;/strong&gt; — set it, always. Without it the schedule runs in the controller-manager's timezone (effectively UTC on most clusters), and "2:15 a.m." silently means something else after a cluster migration. The field takes an IANA name and handles daylight-saving transitions the way the name implies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;concurrencyPolicy&lt;/code&gt;&lt;/strong&gt; — the overlap decision, made explicitly:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Allow&lt;/code&gt; (the default): runs may stack up. Right for independent, cheap, idempotent work; dangerous when a slow run plus a punctual schedule compounds load.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Forbid&lt;/code&gt;: skip a scheduled run while the previous is still going. The right default for most sequential batch work — exports, backups, syncs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Replace&lt;/code&gt;: kill the running Job and start fresh. For "latest state wins" work where a stale run is worse than none.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;startingDeadlineSeconds&lt;/code&gt;&lt;/strong&gt; — if the controller misses a start (downtime, upgrades), how late a run may still fire. Without it, a controller recovering after an outage can launch a burst of stale runs; with it, missed runs beyond the window are skipped. Roughly 100 missed starts is the controller's own safety cap, but your deadline should be far tighter and deliberate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;History limits&lt;/strong&gt; — &lt;code&gt;successfulJobsHistoryLimit&lt;/code&gt; (default 3) and &lt;code&gt;failedJobsHistoryLimit&lt;/code&gt; (default 1) bound how many finished Job objects linger for inspection. Failed history is debugging gold and set lowest by default — raise it if you investigate on Mondays what failed on Fridays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;suspend: true&lt;/code&gt;&lt;/strong&gt; — the pause button. CronJobs honour it immediately; it is the clean way to stop a schedule during an incident without deleting the object.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Failure is a normal state — observe it like one
&lt;/h2&gt;

&lt;p&gt;A service failing pages someone; a Job failing at 2 a.m. writes a status field nobody reads. Close that gap deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alert on Job failure and on CronJob staleness.&lt;/strong&gt; The signals exist in the metrics stack from the observability guide: job-failed conditions, and time since a CronJob's last successful run versus its schedule. "The nightly export hasn't succeeded in 26 hours" is an alert; discovering it from a stale dashboard is an incident review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs outlive pods only if you shipped them.&lt;/strong&gt; Failed pods are deleted by TTL and history limits by design — the pipeline from the logging guide is the memory. At minimum, make the failure visible where the team already looks (the alert carries the job name; the runbook says where its logs live).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish "failed" from "never ran."&lt;/strong&gt; Scheduler outages, suspended CronJobs, image-pull failures before any pod starts — the Job object tells different stories in each case (conditions, events, pod statuses), and the fixes differ. The troubleshooting guide's habit applies: read the object's status before touching anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Resource and disruption behaviour for batch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Requests still matter, limits protect neighbours.&lt;/strong&gt; Batch jobs are the classic noisy neighbour — bursty CPU during everyone else's peak. Set requests honestly (requests-and-limits guide) and consider scheduling heavy batch into off-peak windows or lower-priority classes so services win contention; a report can wait 20 minutes, a checkout cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disruption budgets apply differently.&lt;/strong&gt; A Job's pods are not protected by PDBs the way replicas are (the PDB guide covers voluntary disruptions) — node drains during a long Job mean pod termination and a controller retry. Long jobs should checkpoint progress or be restartable from partial state; the idempotency work above is what makes that survivable rather than catastrophic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-off manual runs:&lt;/strong&gt; &lt;code&gt;kubectl create job --from=cronjob/nightly-export export-manual-1&lt;/code&gt; clones the template for an immediate run — the clean pattern for backfills and tests, far better than hand-writing a twin Job manifest that drifts from the scheduled one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When a Job is the wrong tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long-running or never-finishing work&lt;/strong&gt; is a service — use a Deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-node work&lt;/strong&gt; (agents, collectors) is a DaemonSet's shape, not a Job's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex DAG workflows&lt;/strong&gt; — fan-out/fan-in pipelines, artefact passing, per-step retries with dependencies — outgrow the Job controller quickly. That is the territory of workflow engines (Argo Workflows and kin); the Job primitives remain the right answer for the large simple majority: one task, a schedule or a trigger, a definition of done.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;restartPolicy: Always&lt;/code&gt; copied from a Deployment.&lt;/strong&gt; The API rejects it for Jobs — usually discovered at apply time in a hurry. The template is not interchangeable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No deadline.&lt;/strong&gt; A hung Job holds resources and blocks &lt;code&gt;Forbid&lt;/code&gt;-policy successors indefinitely; the schedule looks fine and nothing runs. &lt;code&gt;activeDeadlineSeconds&lt;/code&gt; is the difference between a failure and a mystery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries without idempotency.&lt;/strong&gt; The double-write pattern above; the controller retries by design, so the hazard is guaranteed to fire eventually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default &lt;code&gt;backoffLimit&lt;/code&gt; and no alerting.&lt;/strong&gt; Six quiet retries, a Failed status, and silence. Batch observability is an alerting decision, not a dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CronJobs in the wrong timezone.&lt;/strong&gt; The 2 a.m. job running at 11 a.m. after a migration is a rite of passage; the &lt;code&gt;timeZone&lt;/code&gt; field is how you skip it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Allow&lt;/code&gt; concurrency on heavy work.&lt;/strong&gt; One slow run plus an hourly schedule equals a self-inflicted load test by morning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets via environment for one-shot jobs.&lt;/strong&gt; Jobs end; their specs (and any env values in them) linger in history objects and etcd. The ConfigMaps and Secrets guide's file-based patterns apply with extra force to objects designed to be kept around as records.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this fits in the series
&lt;/h2&gt;

&lt;p&gt;Batch is the third workload shape in this series, beside services (Deployments) and per-node agents (DaemonSets): it leans on the requests-and-limits guide for fair scheduling, the storage guide when jobs move real data, the backup guide — whose restores and snapshots are themselves usually Jobs — and the autoscaling guide's cluster-autoscaler behaviour, which treats pending batch pods as the elastic demand they are. Model the done-state honestly and the controller does the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Job or CronJob for a task I trigger from CI?&lt;/strong&gt;&lt;br&gt;
A Job, created by the pipeline when the event happens (a migration after deploy, an export on demand). CronJob is for wall-clock schedules; forcing event-driven work into a polling CronJob adds latency and pointless runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did my CronJob not run at the expected time?&lt;/strong&gt;&lt;br&gt;
In order of likelihood: timezone (no &lt;code&gt;timeZone&lt;/code&gt; set, cluster in UTC), the previous run still going under &lt;code&gt;Forbid&lt;/code&gt;, &lt;code&gt;suspend: true&lt;/code&gt; left on, or a missed start beyond &lt;code&gt;startingDeadlineSeconds&lt;/code&gt; after controller downtime. The CronJob's status and events record which.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many times will a failing Job retry?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;backoffLimit&lt;/code&gt; pod failures (default 6), with exponential backoff between attempts — then the Job is marked Failed and stops. With pod failure policies, specific exit codes can fail it sooner, on the first occurrence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a Job run on a schedule &lt;em&gt;and&lt;/em&gt; be triggered manually?&lt;/strong&gt;&lt;br&gt;
Yes — define the CronJob, and create one-off Jobs from its template with &lt;code&gt;kubectl create job --from=cronjob/&amp;lt;name&amp;gt;&lt;/code&gt;. The manual run uses the same spec; it just isn't part of the schedule's history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should batch jobs share the cluster with services?&lt;/strong&gt;&lt;br&gt;
Usually, yes — with honest requests, sane priorities, and schedules that respect peak hours. Dedicated node pools earn their cost when batch is heavy, spiky, or needs special hardware; reach for isolation when contention is measured, not imagined.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>batch</category>
      <category>cronjobs</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Kubernetes RBAC in 2026: Roles, Bindings and Least Privilege That Actually Holds</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:40:23 +0000</pubDate>
      <link>https://dev.to/softverseadmin/kubernetes-rbac-in-2026-roles-bindings-and-least-privilege-that-actually-holds-4080</link>
      <guid>https://dev.to/softverseadmin/kubernetes-rbac-in-2026-roles-bindings-and-least-privilege-that-actually-holds-4080</guid>
      <description>&lt;h1&gt;
  
  
  Kubernetes RBAC in 2026: Roles, Bindings and Least Privilege That Actually Holds
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Kubernetes RBAC fails in practice not because the model is weak but because grants accrete: a wildcard here, a cluster-admin binding there, a "temporary" service account that becomes permanent. The model itself is simple — subjects get verbs on resources in a scope, grants are additive only, and nothing is denied, only not granted. Least privilege that holds means: one service account per workload with token automount off by default, namespace-scoped Roles as the default unit, groups (not individuals) as human subjects, secrets treated as the crown jewels they are, and a regular audit of who can do what — verified with &lt;code&gt;kubectl auth can-i&lt;/code&gt;, not assumed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/kubernetes-rbac-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why RBAC is where cluster security is won or lost
&lt;/h2&gt;

&lt;p&gt;The security-basics guide in this series covered the checklist; this guide goes deep on the one control that decides what a compromised credential can actually do. Most cluster incidents follow the same shape: an attacker (or a fat-fingered script) obtains a token — from a pod, a CI job, a leaked kubeconfig — and the blast radius is whatever that token may do. Network policies limit where it can talk; RBAC limits what it can &lt;em&gt;be&lt;/em&gt;. A read-only token in one namespace is an embarrassment. A cluster-admin token is a breach report.&lt;/p&gt;

&lt;p&gt;And RBAC sprawl is the default outcome, because every grant is a small act of unblocking someone: the deploy pipeline "needs" broader rights, the dashboard "needs" cluster-wide secrets, the new tool "needs" admin "for setup." Each is reasonable on the day. Together they are the incident, pre-positioned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model, precisely
&lt;/h2&gt;

&lt;p&gt;Four ideas carry the whole system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subjects&lt;/strong&gt; — who: a &lt;strong&gt;User&lt;/strong&gt; (external identity, typically from a certificate or OIDC token), a &lt;strong&gt;Group&lt;/strong&gt;, or a &lt;strong&gt;ServiceAccount&lt;/strong&gt; (in-cluster identity).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules&lt;/strong&gt; — what: &lt;strong&gt;verbs&lt;/strong&gt; (&lt;code&gt;get&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;watch&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;patch&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;, and a few specials) on &lt;strong&gt;resources&lt;/strong&gt; (&lt;code&gt;pods&lt;/code&gt;, &lt;code&gt;deployments&lt;/code&gt;, &lt;code&gt;secrets&lt;/code&gt;…) within &lt;strong&gt;API groups&lt;/strong&gt; (&lt;code&gt;""&lt;/code&gt; for core, &lt;code&gt;apps&lt;/code&gt;, &lt;code&gt;batch&lt;/code&gt;, &lt;code&gt;rbac.authorization.k8s.io&lt;/code&gt;…).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope&lt;/strong&gt; — where: a &lt;strong&gt;Role&lt;/strong&gt; grants within one namespace; a &lt;strong&gt;ClusterRole&lt;/strong&gt; grants cluster-wide or for cluster-scoped resources (nodes, persistent volumes, namespaces themselves).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bindings&lt;/strong&gt; — the connection: a &lt;strong&gt;RoleBinding&lt;/strong&gt; attaches a Role &lt;em&gt;or&lt;/em&gt; ClusterRole to subjects &lt;strong&gt;inside one namespace&lt;/strong&gt; (a ClusterRole bound by a RoleBinding is effectively trimmed to that namespace); a &lt;strong&gt;ClusterRoleBinding&lt;/strong&gt; attaches a ClusterRole everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two properties shape everything else:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Additive only.&lt;/strong&gt; There are no deny rules. A subject's rights are the union of all its bindings. "Remove the bad grant" always means deleting or narrowing a binding — and "who granted what" questions are answered by enumerating bindings, never by finding an exception.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default deny.&lt;/strong&gt; With RBAC as the authorisation mode, a subject with no grants can do nothing. Every capability is an explicit decision — which is why the accretion pattern above is a process failure, not a model failure.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A worked example: read-only log access for support
&lt;/h2&gt;

&lt;p&gt;The support team needs to read pods and logs in one production namespace — nothing else, nowhere else:&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;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Role&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;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-prod&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;log-reader&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods/log"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RoleBinding&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;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-prod&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;log-reader-support&lt;/span&gt;
&lt;span class="na"&gt;subjects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&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;Group&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;support-engineers&lt;/span&gt;
    &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io&lt;/span&gt;
&lt;span class="na"&gt;roleRef&lt;/span&gt;&lt;span class="pi"&gt;:&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;Role&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;log-reader&lt;/span&gt;
  &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details doing quiet work here: the subject is a &lt;strong&gt;Group&lt;/strong&gt; (people join and leave groups; bindings should not name individuals), &lt;code&gt;pods/log&lt;/code&gt; is a subresource that must be granted explicitly, and there is no &lt;code&gt;secrets&lt;/code&gt;, no &lt;code&gt;create&lt;/code&gt;, no second namespace. Verify it the way an auditor would:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl auth can-i get pods &lt;span class="nt"&gt;--namespace&lt;/span&gt; shop-prod &lt;span class="nt"&gt;--as&lt;/span&gt; jane &lt;span class="nt"&gt;--as-group&lt;/span&gt; support-engineers   &lt;span class="c"&gt;# yes&lt;/span&gt;
kubectl auth can-i get secrets &lt;span class="nt"&gt;--namespace&lt;/span&gt; shop-prod &lt;span class="nt"&gt;--as&lt;/span&gt; jane &lt;span class="nt"&gt;--as-group&lt;/span&gt; support-engineers &lt;span class="c"&gt;# no&lt;/span&gt;
kubectl auth can-i list pods &lt;span class="nt"&gt;--all-namespaces&lt;/span&gt; &lt;span class="nt"&gt;--as&lt;/span&gt; jane &lt;span class="nt"&gt;--as-group&lt;/span&gt; support-engineers         &lt;span class="c"&gt;# no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl auth can-i&lt;/code&gt; with &lt;code&gt;--as&lt;/code&gt;/&lt;code&gt;--as-group&lt;/code&gt; impersonation is the ground-truth test for any identity — build the habit of checking grants with it instead of reading YAML and reasoning hopefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service accounts: the identities that actually get stolen
&lt;/h2&gt;

&lt;p&gt;Humans are rarely the compromised identity; workloads are. Service-account discipline is therefore the highest-value RBAC work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One service account per workload.&lt;/strong&gt; The &lt;code&gt;default&lt;/code&gt; service account in each namespace should have &lt;em&gt;no&lt;/em&gt; grants. A dedicated account per deployment makes grants reviewable and revocation surgical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn off token automount unless needed.&lt;/strong&gt; Most applications never call the Kubernetes API. For them the token is pure attack surface:
&lt;/li&gt;
&lt;/ul&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;ServiceAccount&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;shop-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-prod&lt;/span&gt;
&lt;span class="na"&gt;automountServiceAccountToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The same field exists per-pod in the pod spec, which wins when both are set.) Where a token &lt;em&gt;is&lt;/em&gt; needed, modern clusters issue short-lived projected (bound) tokens rather than long-lived secrets — prefer them, and treat any long-lived service-account token secret as a credential to inventory.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deployer accounts are prime targets.&lt;/strong&gt; A CI service account that can create deployments in production can, by design, run arbitrary images with that namespace's service accounts — near-admin inside its scope. Narrow its verbs to exactly the resources the pipeline applies, keep it namespace-scoped, and protect its credential like a production key (the OIDC guide's keyless pattern applies to how the pipeline authenticates to the cloud; the in-cluster account deserves the same suspicion).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  ClusterRoles: handle with both hands
&lt;/h2&gt;

&lt;p&gt;Cluster-wide grants are sometimes right — an observability agent reading node and pod metadata everywhere, an operator managing its resource type across namespaces. The disciplines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefer a trimmed custom role over the built-ins' extremes.&lt;/strong&gt; The default &lt;code&gt;cluster-admin&lt;/code&gt; is total; &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, and &lt;code&gt;view&lt;/code&gt; are namespace-oriented defaults with specific shapes (notably, &lt;code&gt;view&lt;/code&gt; excludes secrets — a deliberate, instructive choice). For cluster-wide read, write the ClusterRole that lists exactly the resources the tool reads; "cluster-reader" patterns from the community are a starting point, not a destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregation composes, so audit the aggregate.&lt;/strong&gt; A ClusterRole with an &lt;code&gt;aggregationRule&lt;/code&gt; collects rules from labelled ClusterRoles (this is how the built-in admin/edit/view roles grow with CRDs). Powerful for platforms, dangerous when a stray label quietly widens a widely bound role — include aggregated roles in audits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClusterRoleBindings deserve a list, kept short.&lt;/strong&gt; Every entry is a cluster-wide statement. Review the full list on a schedule; in a healthy cluster it fits on one screen and every line has an owner who can explain it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The dangerous verbs and resources
&lt;/h2&gt;

&lt;p&gt;Audits should hunt specific shapes, because damage concentrates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wildcards&lt;/strong&gt; — &lt;code&gt;verbs: ["*"]&lt;/code&gt; or &lt;code&gt;resources: ["*"]&lt;/code&gt; in anything but a deliberately tiny set of admin roles. A wildcard silently includes tomorrow's new subresources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;get&lt;/code&gt;/&lt;code&gt;list&lt;/code&gt; on &lt;code&gt;secrets&lt;/code&gt;&lt;/strong&gt; — especially cluster-wide. Reading secrets is effectively holding every credential in scope (the ConfigMaps and Secrets guide covers why). Treat any new secrets grant as a security review, not a config change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;create&lt;/code&gt; on &lt;code&gt;pods&lt;/code&gt;&lt;/strong&gt; — pod creation with a chosen service account and mounts is a well-trodden path to privilege escalation; so are &lt;code&gt;create&lt;/code&gt; on rolebindings/clusterrolebindings and the special &lt;code&gt;bind&lt;/code&gt; and &lt;code&gt;escalate&lt;/code&gt; verbs, which exist precisely to gate grant-making itself. &lt;strong&gt;&lt;code&gt;impersonate&lt;/code&gt;&lt;/strong&gt; is equally sensitive: it &lt;em&gt;is&lt;/em&gt; the other identity, for whoever holds it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;deletecollection&lt;/code&gt;&lt;/strong&gt; and broad &lt;code&gt;delete&lt;/code&gt; — the availability side of the same coin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Human access: groups, short-lived credentials, no shared kubeconfigs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Map your identity provider's &lt;strong&gt;groups&lt;/strong&gt; to Group subjects once, and manage membership in the IdP — not by editing bindings per person. Joiner/leaver processes then work through the system people already trust for access changes.&lt;/li&gt;
&lt;li&gt;Issue &lt;strong&gt;short-lived&lt;/strong&gt; credentials: exec-plugin/OIDC flows from your cloud or IdP, so a copied kubeconfig expires instead of opening a permanent door. A long-lived client certificate cannot be revoked (Kubernetes has no certificate revocation for this) — another reason to prefer token-based, expiring auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Break-glass, deliberately.&lt;/strong&gt; Keep an emergency admin path, alarm on its use, and test it — an incident is the wrong time to discover nobody can grant the rights to fix it. Emergency access is a designed feature; routine access through it is the failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Auditing: make "who can do what" a query, not a debate
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kubectl auth can-i --list --as &amp;lt;identity&amp;gt;&lt;/code&gt; prints an identity's effective rights in a namespace — the fastest spot check.&lt;/li&gt;
&lt;li&gt;Enumerate bindings as code review: RoleBindings and ClusterRoleBindings are YAML in your GitOps repo (the Argo CD guide's pattern) — changes to them should be pull requests with security-aware reviewers, not &lt;code&gt;kubectl&lt;/code&gt; improvisation.&lt;/li&gt;
&lt;li&gt;Tooling in the &lt;code&gt;kubectl-who-can&lt;/code&gt; / RBAC-lookup family answers the reverse question ("who can get secrets in prod?") across the cluster; run it in CI or on a schedule and diff the output — new entries are events worth a human glance.&lt;/li&gt;
&lt;li&gt;API-server &lt;strong&gt;audit logs&lt;/strong&gt; record who did what (the logging guide covers collecting them); RBAC denials in those logs are a signal of both attacks and of teams fighting their own permissions — both worth knowing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Binding cluster-admin "temporarily."&lt;/strong&gt; Temporary grants are permanent until the audit that never happens. Time-box with a ticket, an owner, and a removal date — or better, grant the specific role the task needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One service account for everything in the namespace.&lt;/strong&gt; Every pod inherits the union of rights; one compromised container spends them all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets access for convenience.&lt;/strong&gt; Dashboards and debug tools asking for cluster-wide secrets are asking for the keys to everything. Scope to the namespace, or redesign the tool's access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confusing RoleBinding-to-ClusterRole scope.&lt;/strong&gt; Binding a ClusterRole via a RoleBinding grants it &lt;strong&gt;only in that namespace&lt;/strong&gt; — teams regularly believe they granted (or limited) something else. Read the binding kind, not just the role kind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale bindings.&lt;/strong&gt; People change teams; projects end; vendor tools are uninstalled and their service accounts and bindings remain, valid and forgotten. The audit diff is how they surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing with your own admin account.&lt;/strong&gt; It works for you, so permissions "work." Test as the workload's identity (&lt;code&gt;--as=system:serviceaccount:ns:name&lt;/code&gt; or by running the pod) before the 3 a.m. discovery.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this fits in the series
&lt;/h2&gt;

&lt;p&gt;RBAC is the identity layer the rest of the series assumes: the security-basics guide sets the posture, the network policies guide constrains where identities can reach, the ConfigMaps and Secrets guide protects what grants can expose, and the observability and logging guides watch it all — including the audit stream that records every grant being used. Get RBAC boring, and the other controls get to do their jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can RBAC deny a specific action?&lt;/strong&gt;&lt;br&gt;
No. RBAC is additive — a subject's permissions are the union of its grants, and there is no deny rule to carve exceptions. If a grant is too broad, narrow the role or split it; for true deny semantics (blocking an action even for the granted), admission-time policy tools are the layer that does it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Role or ClusterRole for a cross-namespace tool?&lt;/strong&gt;&lt;br&gt;
If it genuinely acts in many namespaces, a ClusterRole — bound cluster-wide only if it must act everywhere, or via RoleBindings in selected namespaces when its reach should be enumerated namespace by namespace. The second pattern keeps the grant list explicit and reviewable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should each pod have its own service account?&lt;/strong&gt;&lt;br&gt;
Each &lt;em&gt;workload identity&lt;/em&gt; should: one per deployment/workload is the practical unit, shared by its replicas. Per-pod accounts multiply objects without adding a trust boundary; per-namespace sharing erases the boundary that exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I see everything a service account can do?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;kubectl auth can-i --list --as=system:serviceaccount:&amp;lt;namespace&amp;gt;:&amp;lt;name&amp;gt; -n &amp;lt;namespace&amp;gt;&lt;/code&gt; for a namespace view, repeated per namespace or answered cluster-wide with RBAC lookup tooling. Make it a habit after every binding change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are the built-in admin/edit/view roles safe to use?&lt;/strong&gt;&lt;br&gt;
They are reasonable, maintained defaults — with shapes you should know: &lt;code&gt;view&lt;/code&gt; excludes secrets and most write access, &lt;code&gt;edit&lt;/code&gt; adds writes but not role management, &lt;code&gt;admin&lt;/code&gt; adds role management within the namespace but not namespace deletion or quota changes. The risk is rarely the built-ins; it is the custom wildcard beside them.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>rbac</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Terraform Testing in 2026: Unit Tests With terraform test and Mock Providers</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:23:47 +0000</pubDate>
      <link>https://dev.to/softverseadmin/terraform-testing-in-2026-unit-tests-with-terraform-test-and-mock-providers-2e5k</link>
      <guid>https://dev.to/softverseadmin/terraform-testing-in-2026-unit-tests-with-terraform-test-and-mock-providers-2e5k</guid>
      <description>&lt;h1&gt;
  
  
  Terraform Testing in 2026: Unit Tests With terraform test and Mock Providers
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Most Terraform changes are still reviewed by reading a plan and hoping. The native test framework (&lt;code&gt;terraform test&lt;/code&gt;, stable since Terraform 1.6) changes that: test files declare inputs, run a plan or apply, and assert on the results — and with mock providers (since 1.7) the logic tests run in CI in seconds with no cloud account and no credentials. Test your modules' contracts — outputs, conditional resources, naming and tagging rules — not the cloud provider's behaviour. Keep a small number of real apply tests for critical modules in an ephemeral sandbox, and let linters and scanners do their separate jobs alongside.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/terraform-testing-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the plan is not a test
&lt;/h2&gt;

&lt;p&gt;Application code gets unit tests as a matter of course. Infrastructure code — which can delete a database just as dead as any application bug — usually gets a human skimming &lt;code&gt;terraform plan&lt;/code&gt; output in a pull request. That review catches typos. It does not catch the conditional that inverted last refactor, the variable default that quietly changed a bucket from private to public, or the count logic that creates zero replicas of the thing production needs three of.&lt;/p&gt;

&lt;p&gt;The reason was tooling: for years, testing Terraform meant heavyweight frameworks or applying to a real account and inspecting by hand. The native framework removes the excuse. Tests live next to the module, run with one command, and — the decisive part — can run against &lt;strong&gt;mocked providers&lt;/strong&gt;, so the suite executes like unit tests: fast, hermetic, credential-free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is worth testing (and what is not)
&lt;/h2&gt;

&lt;p&gt;Test &lt;strong&gt;your module's decisions&lt;/strong&gt;, not the provider's implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Outputs and contracts.&lt;/strong&gt; Callers depend on output values and shapes. If &lt;code&gt;bucket_name&lt;/code&gt; changes format, downstream code breaks — a test should break first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conditional logic.&lt;/strong&gt; Every &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;for_each&lt;/code&gt;, and ternary is a decision your module makes: versioning on/off, optional resources created or skipped, single vs multi-AZ shapes. Each branch deserves a run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Naming, tagging, and policy invariants.&lt;/strong&gt; The rules platform teams actually care about: names compose from project/environment correctly, required tags are always present, encryption is enabled, nothing is public unless an explicit variable says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Variable edge cases.&lt;/strong&gt; Empty lists, zero counts, nulls — the inputs real callers will eventually pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; test that AWS creates an S3 bucket when asked, that an attribute AWS generates looks a certain way beyond your control, or other provider internals. Those tests are slow, brittle, and verify someone else's software.&lt;/p&gt;

&lt;h2&gt;
  
  
  The framework in one module
&lt;/h2&gt;

&lt;p&gt;A small module to test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.tf&lt;/span&gt;
&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"project"&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"environment"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"enable_versioning"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bool&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project}-${var.environment}-assets"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_versioning"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;versioning_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enable_versioning&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"Enabled"&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Suspended"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"bucket_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And its test file (conventionally in a &lt;code&gt;tests/&lt;/code&gt; directory inside the module):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/bucket.tftest.hcl&lt;/span&gt;
&lt;span class="nx"&gt;mock_provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="nx"&gt;variables&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;project&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"shop"&lt;/span&gt;
  &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"versioning_on_by_default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;

  &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket_versioning&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;versioning_configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Enabled"&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Versioning must be enabled by default"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket_name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"shop-prod-assets"&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Bucket name must compose project and environment"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"versioning_can_be_disabled"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;

  &lt;span class="nx"&gt;variables&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;enable_versioning&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket_versioning&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;versioning_configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Suspended"&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Versioning should suspend when explicitly disabled"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;terraform test&lt;/code&gt; from the module root. The anatomy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mock_provider "aws" {}&lt;/code&gt;&lt;/strong&gt; replaces the real provider. Terraform generates fake computed values, so a &lt;code&gt;plan&lt;/code&gt; (or even an &lt;code&gt;apply&lt;/code&gt;) runs without credentials, network, or a cloud account — in milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;variables {}&lt;/code&gt;&lt;/strong&gt; at file level sets shared inputs; a &lt;code&gt;run&lt;/code&gt; block can override them per case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;run&lt;/code&gt; blocks&lt;/strong&gt; are the test cases. &lt;code&gt;command = plan&lt;/code&gt; is the default workhorse; &lt;code&gt;command = apply&lt;/code&gt; executes against the (possibly mocked) provider for shape checks that only appear after apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;assert&lt;/code&gt;&lt;/strong&gt; pairs a condition over any value in the configuration — resources, outputs, variables, locals — with the &lt;code&gt;error_message&lt;/code&gt; a human reads when it fails. Write the messages as explanations, not echoes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where a mocked computed value needs to be specific — an ARN a policy references, an ID another resource consumes — &lt;code&gt;override_resource&lt;/code&gt; and &lt;code&gt;override_data&lt;/code&gt; blocks pin those values inside a run. Use them sparingly; a test that overrides everything asserts nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mock providers: what they change, and their honest limits
&lt;/h2&gt;

&lt;p&gt;Before mocks, a "unit test" of a module still configured the real provider, which meant credentials in CI, provider API calls in every plan, and suites measured in minutes. Mocks invert all three: the pipeline job needs &lt;strong&gt;no cloud credentials at all&lt;/strong&gt; for the test stage (which, per the keyless-CI pattern in the GitHub Actions OIDC guide on this site, is exactly where you want the fewest credentials), tests run at unit-test speed, and nobody's sandbox account accumulates test debris.&lt;/p&gt;

&lt;p&gt;The limits are equally clear, and stating them keeps the suite trustworthy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mocks validate &lt;strong&gt;your configuration logic against the provider's schema&lt;/strong&gt;, not against the real API. A value the real AWS API would reject (an invalid name pattern, an unsupported combination) can sail through a mocked plan.&lt;/li&gt;
&lt;li&gt;Data sources return generated values unless overridden — tests depending on a data source's real content need overrides or a real read.&lt;/li&gt;
&lt;li&gt;Provider-side quirks and eventual-consistency behaviour are, by definition, absent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the strategy is layered: mocked tests for logic, in bulk; a few real tests for reality, on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The top of the pyramid: real apply tests, kept few
&lt;/h2&gt;

&lt;p&gt;For a critical module — the network foundation, the shared database module — a mocked plan cannot prove the one thing that matters: it actually builds. Keep a small suite of &lt;code&gt;command = apply&lt;/code&gt; runs against the real provider in a &lt;strong&gt;sandbox account&lt;/strong&gt; with three disciplines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ephemeral by design.&lt;/strong&gt; Tests create, assert, and the framework destroys what it created at the end of the run (that is the framework's contract for test-created resources). Verify the destroy happened; orphaned test resources are a cost and security smell — the state-management guide's hygiene rules apply to test state too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate credentials, narrow scope.&lt;/strong&gt; The sandbox role can create and destroy only the resource types under test, in the sandbox account only. The OIDC guide's per-environment role pattern fits exactly here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled, not per-commit.&lt;/strong&gt; Real applies take minutes and cost real (if small) money. Run them nightly or on module release, while the mocked suite gates every pull request.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where tests sit among the other tools
&lt;/h2&gt;

&lt;p&gt;Terraform already has a quality neighbourhood, and the test framework does not evict anyone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Variable validation blocks&lt;/strong&gt; — the module rejecting bad input at plan time. First line of defence, and the test suite should cover that they fire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform fmt&lt;/code&gt; / &lt;code&gt;validate&lt;/code&gt;&lt;/strong&gt; — shape and syntax. Free; run always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tflint and friends&lt;/strong&gt; — lint rules, naming conventions, deprecated syntax. Style and hygiene, not behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkov / tfsec-style scanners&lt;/strong&gt; — policy and security posture of the configuration. They check &lt;em&gt;what the config says&lt;/em&gt;; tests check &lt;em&gt;what the module decides&lt;/em&gt; given inputs. Complementary, not overlapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform test&lt;/code&gt;&lt;/strong&gt; — behaviour: outputs, conditionals, invariants across input variations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A sane pipeline order: fmt check → validate → lint/scan → &lt;code&gt;terraform test&lt;/code&gt; (mocked) → plan for human review. Each stage is cheap; together they mean the human reviews intent, not correctness arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modules are where tests pay rent
&lt;/h2&gt;

&lt;p&gt;For a root configuration used once, tests are nice. For &lt;strong&gt;modules&lt;/strong&gt; — shared, versioned, consumed by teams who did not write them — they are the contract enforcement mechanism. The modules guide in this series covers structuring reusable modules; tests are what let a maintainer refactor a module's internals while proving its callers see the same outputs, the same resource decisions, the same invariants. That confidence is precisely what a module library runs on, and it is why test files should ship inside the module repository, versioned with the code they describe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Testing the provider.&lt;/strong&gt; Asserting that a mocked (or real) AWS resource has AWS-chosen properties teaches the suite to fail for other people's changes. Assert your decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overriding everything.&lt;/strong&gt; Heavy &lt;code&gt;override_resource&lt;/code&gt; use pins the fake world so completely that the test passes regardless of the configuration. Overrides are scalpels for specific computed values, not a substitute world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One giant run block.&lt;/strong&gt; A failed assert should name the broken decision. Many small runs with descriptive names beat one mega-run with forty asserts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply tests against shared accounts.&lt;/strong&gt; Test resources in an account other teams use will eventually collide, linger, or be "cleaned up" by someone mid-run. Sandbox account, always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mocked-only confidence for critical paths.&lt;/strong&gt; The suite is green, the module cannot actually apply. Keep the few real apply tests for the modules whose failure would hurt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring test state.&lt;/strong&gt; &lt;code&gt;terraform test&lt;/code&gt; manages its own state for runs, but interrupted real applies can leave residue; a periodic sandbox sweep belongs in the routine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing tests once, at the end.&lt;/strong&gt; The suite earns its keep when a refactor lands six months later. Add the run block when you add the conditional — that is when the intent is clearest and cheapest to encode.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;terraform test or Terratest?&lt;/strong&gt;&lt;br&gt;
They answer different questions. &lt;code&gt;terraform test&lt;/code&gt; is native, HCL-based, and ideal for module logic and contracts — no Go toolchain, no extra framework. Terratest (Go) remains strong for end-to-end verification that spans tools and real cloud behaviour beyond one module. Many teams use both: native tests in every module repo, Terratest-style end-to-end checks for platform assemblies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;terraform test&lt;/code&gt; cost money?&lt;/strong&gt;&lt;br&gt;
Mocked and plan-only runs create nothing and cost nothing. &lt;code&gt;command = apply&lt;/code&gt; runs against a real provider create real resources for the duration of the test — priced accordingly, then destroyed by the framework. Keep them few, small, and in a sandbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can tests run in CI without cloud credentials?&lt;/strong&gt;&lt;br&gt;
Mocked-provider tests: yes, completely — that is their headline feature. Plan runs against a real provider still need whatever credentials that provider requires to configure itself, which is one more reason the mocked suite should carry the bulk of the assertions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where do test files live?&lt;/strong&gt;&lt;br&gt;
In the module root or a &lt;code&gt;tests/&lt;/code&gt; subdirectory (the conventional choice), as &lt;code&gt;*.tftest.hcl&lt;/code&gt;. &lt;code&gt;terraform test&lt;/code&gt; discovers them in both places; keep one convention per repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about OpenTofu?&lt;/strong&gt;&lt;br&gt;
OpenTofu implements the same test framework model — test files, run blocks, and mock providers are compatible concepts, so the approach in this guide carries across. Check your version's documentation for exact feature parity before assuming a specific block behaves identically.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>testing</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Kubernetes Logging in 2026: From kubectl logs to a Pipeline That Scales</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:13:41 +0000</pubDate>
      <link>https://dev.to/softverseadmin/kubernetes-logging-in-2026-from-kubectl-logs-to-a-pipeline-that-scales-52ci</link>
      <guid>https://dev.to/softverseadmin/kubernetes-logging-in-2026-from-kubectl-logs-to-a-pipeline-that-scales-52ci</guid>
      <description>&lt;h1&gt;
  
  
  Kubernetes Logging in 2026: From kubectl logs to a Pipeline That Scales
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; In Kubernetes, logs die with the pod — and the pods you most need logs from are the ones that just died. The fix is an architecture, not a tool: applications write structured logs to stdout/stderr, a node-level agent (Fluent Bit, usually) collects and enriches them with Kubernetes metadata, and a backend (Loki, OpenSearch, or your cloud's) stores them with retention you chose on purpose. Add correlation IDs so a log line connects to a request and a trace, control the volume before it controls your budget, and test the pipeline by killing a pod and finding its last words.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/kubernetes-logging-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the evidence deletes itself
&lt;/h2&gt;

&lt;p&gt;On a VM, a crashed application's logs sat on disk until someone looked. In Kubernetes, the sequence runs the other way: the pod crashes, gets rescheduled or replaced, the node eventually cleans up, and the logs — the primary evidence of what happened — evaporate on a schedule driven by everything except your investigation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl logs&lt;/code&gt; papers over this for exactly one generation: it shows the current container, and with &lt;code&gt;-p&lt;/code&gt; the previous one. Before that? Gone. On another node after a reschedule? Gone. From the DaemonSet that was OOM-killed at 3 a.m.? Never existed as far as you can prove.&lt;/p&gt;

&lt;p&gt;So the troubleshooting guide in this series starts with logs — and production logging is the discipline of making sure they are somewhere else before the pod takes them away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: stdout is the application's only logging API
&lt;/h2&gt;

&lt;p&gt;The rule from which everything else follows: &lt;strong&gt;a containerised application logs to stdout and stderr, and nowhere else.&lt;/strong&gt; No log files inside the container, no application-managed rotation, no "the file is on the volume somewhere." The container runtime captures those two streams to files on the node (&lt;code&gt;/var/log/containers/…&lt;/code&gt;), and the platform takes it from there.&lt;/p&gt;

&lt;p&gt;This buys three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uniformity.&lt;/strong&gt; Every workload, every language, same contract. The pipeline never needs per-application file paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotation for free&lt;/strong&gt; (with a caveat below): the kubelet and runtime rotate the node-side files; the application cannot fill its own container filesystem with logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streams stay separable.&lt;/strong&gt; stderr is not automatically "errors" — plenty of frameworks log everything to stdout — but having both captured beats parsing one merged file inside a dying container.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application-side obligations that make this work: one event per line, and &lt;strong&gt;structured&lt;/strong&gt; — JSON lines with a timestamp, a level, a message, and the fields a future searcher will filter on (service, version, request ID). Plain-text prose logs are a tax every incident pays at the worst exchange rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The collector: a node-level agent
&lt;/h2&gt;

&lt;p&gt;The standard shape is one log collector per node, running as a DaemonSet, tailing the runtime's log files, enriching each record with Kubernetes metadata (namespace, pod, container, labels — fetched from the API), parsing structure, and shipping to the backend.&lt;/p&gt;

&lt;p&gt;The usual choice in 2026 is &lt;strong&gt;Fluent Bit&lt;/strong&gt;: small, fast, CNCF-graduated, written for exactly this job. &lt;strong&gt;Fluentd&lt;/strong&gt; (its older sibling) remains capable, especially where a team already runs its plugin ecosystem; a few platforms ship their own agents. What matters more than the brand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Metadata enrichment is non-negotiable.&lt;/strong&gt; A log line without namespace/pod/labels is nearly unsearchable at cluster scale. Verify your pipeline adds them — silently missing metadata is the most common logging bug in production clusters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffering and backpressure.&lt;/strong&gt; When the backend is unreachable, the agent must buffer (to disk, with a cap) and retry — and its buffer behaviour must not be allowed to fill the node disk it is protecting you from. Size it, alert on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent is production software.&lt;/strong&gt; It runs on every node, touches every log line, and fails silently when misconfigured. It belongs in the observability stack's alerting (the observability guide covers the wider stack): DaemonSet pods healthy, shipping lag near zero, drop counters at zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A note on &lt;strong&gt;audit logs&lt;/strong&gt;: the API server's audit stream is a different animal — who did what to the cluster, the security record from the security-basics guide — collected at the control plane, retained separately, and never mixed into the application pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where logs land: pick the backend by the questions you ask
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loki&lt;/strong&gt; — indexes only labels, stores compressed log chunks in object storage. Cheap to run, excellent when your queries are "this service, this namespace, this time window, containing this string" — which is most incident queries. Pairs with Grafana next to your metrics. Its discipline: &lt;strong&gt;labels are precious and few&lt;/strong&gt; (service, namespace, level, environment). A user ID or request ID in a label explodes the index (the cardinality trap); those belong in the log line or in structured fields, not labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Elasticsearch / OpenSearch&lt;/strong&gt; — full-text indexing of everything: expensive in storage and operations, unmatched when you genuinely need arbitrary field search across huge volumes and long retention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud-native sinks&lt;/strong&gt; — CloudWatch Logs, Google Cloud Logging, Azure Monitor: zero extra infrastructure on the matching managed service, pricing that rewards a retention policy, and query languages your team may like less than LogQL. For many EKS/GKE/AKS clusters this is the pragmatic default, with the node agent either replaced by the provider's or forwarding to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any of the three works. The expensive mistakes are the two defaults nobody chose: keeping everything forever, and discovering your per-GB ingestion bill during the incident that tripled log volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sidecar pattern: when node-level isn't enough
&lt;/h2&gt;

&lt;p&gt;Some applications will not log to stdout — legacy software writing to files, multi-line stack traces an application insists on assembling itself, or a workload needing local pre-processing. The pattern: a &lt;strong&gt;sidecar container&lt;/strong&gt; in the same pod that reads the file from a shared volume and writes it to its own stdout (so the node agent picks it up normally), or ships directly to the backend. It works, at the price of a container per pod — resources, complexity, one more thing to upgrade. Reach for it per-workload, knowingly, not as the cluster default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log survival details that bite
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rotation is configured at the node, and it has a size.&lt;/strong&gt; Runtime/kubelet log rotation (commonly around 10 MiB per file, a handful of files) means a chatty container's history on the node may cover minutes, not hours. That is fine &lt;em&gt;because the agent ships lines as they are written&lt;/em&gt; — but it is why "the agent was down for an hour" translates directly into "that hour's logs exist only where the agent later recovered them, if it could." Agent health is log retention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node disk is a shared fate.&lt;/strong&gt; Logs, images, and emptyDir volumes all eat the same disk; a runaway logger can trigger disk-pressure evictions of innocent pods (the storage guide covers the disk side). Resource discipline — requests and limits from that guide — applies to your loggiest workloads first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl logs&lt;/code&gt; still matters.&lt;/strong&gt; For live debugging it is the fastest tool that exists: &lt;code&gt;kubectl logs -l app=api --since=10m --all-containers=true&lt;/code&gt; across a label selector, &lt;code&gt;--previous&lt;/code&gt; for the crashed instance, &lt;code&gt;--follow&lt;/code&gt; during a rollout. The pipeline is for history and search; kubectl is for now. Engineers should be fluent in both.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Correlation: logs that connect to everything else
&lt;/h2&gt;

&lt;p&gt;A log stream alone answers "what did this service print." Production debugging asks a bigger question: "what happened to &lt;em&gt;this request&lt;/em&gt;." The bridge is a &lt;strong&gt;correlation ID&lt;/strong&gt;: generated at the edge (or taken from an incoming header), propagated through every service call, and printed in every log line and trace span. With that one discipline, an incident moves from grepping five services by timestamp to one query per system, all keyed by the same ID — and the metrics–traces–logs triad from the observability guide becomes a single investigation instead of three.&lt;/p&gt;

&lt;p&gt;Two supporting habits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Levels with meaning.&lt;/strong&gt; &lt;code&gt;ERROR&lt;/code&gt; should mean "a human may need to act," not "a retry happened." When everything is an error, nothing is; alert on error &lt;em&gt;rate&lt;/em&gt; changes, not on the existence of error lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never log secrets or personal data.&lt;/strong&gt; Tokens, passwords, full request bodies with credentials, unnecessary personal fields — logs are copied, retained, and widely readable by design; treat them as the least-trusted storage you own. Redaction at the application beats scrubbing in the pipeline, and both beat explaining a credential found in a log bucket (the ConfigMaps and Secrets guide covers the secret-handling side).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Volume, retention, and the bill
&lt;/h2&gt;

&lt;p&gt;Log volume scales with traffic and verbosity, and both spike during incidents — exactly when you can least afford dropped data or a surprise invoice. The controls, in the order teams usually meet them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Retention tiers.&lt;/strong&gt; Hot storage (fast search) for days-to-weeks; cheaper object storage for months; deletion after the period your compliance actually requires. "Forever" is a decision too — an expensive one nobody remembers making.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop or sample the noise.&lt;/strong&gt; Health-check access logs from internal probes, debug output from one misbehaving workload, a namespace that logs every cache hit — filter at the agent, where dropping is free, rather than at the backend, where you have already paid to ingest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-namespace visibility.&lt;/strong&gt; Know which workloads produce the volume. The cost guide in this series makes the same point about compute; logs deserve the same per-team accounting, because the fix is always a conversation with one workload's verbosity.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Prove it: the kill-a-pod test
&lt;/h2&gt;

&lt;p&gt;Like backups and disruption budgets before it in this series, a logging pipeline is a rumour until exercised. The test is five minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick a workload. In its logs, find (or generate) a unique marker line — a distinctive request ID works.&lt;/li&gt;
&lt;li&gt;Delete the pod. Wait for the node to forget it ever existed.&lt;/li&gt;
&lt;li&gt;From the backend alone, retrieve that pod's final log lines, filtered by namespace, pod name, and the marker. Check the metadata is attached and the timestamps are ordered and correct.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 3 fails, you do not have a logging pipeline — you have a logging intention. Run the test after every agent upgrade and every backend change, and monitor the agent continuously in between; silent gaps are this system's natural failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Applications logging to files inside containers.&lt;/strong&gt; The data is invisible to the platform, dies with the pod, and fills the container filesystem on the way out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unstructured prose logs.&lt;/strong&gt; Every incident becomes a regex-writing contest. JSON lines with stable fields, from day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-cardinality labels.&lt;/strong&gt; Request IDs, user IDs, and pod IPs as index labels: the backend slows, costs climb, and the fix (re-indexing your mental model) is painful. Labels identify the stream; fields describe the event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No one owns agent health.&lt;/strong&gt; The DaemonSet falls behind or dies on two nodes; the gap is discovered mid-incident, which is the only time it cannot be fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debug level in production&lt;/strong&gt;, forever, because one incident needed it once. Verbosity is a per-workload dial with a cost meter attached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets in logs.&lt;/strong&gt; The widest-read storage in your stack is the worst place for the most sensitive strings you own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention set by the tool's default&lt;/strong&gt; instead of by your actual compliance and debugging needs — too short loses investigations, too long buys nothing but storage bills.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this fits in the series
&lt;/h2&gt;

&lt;p&gt;Logging is the third pillar the observability guide stands on, and the raw material the troubleshooting guide consumes first. It leans on the storage guide's disk discipline, the requests-and-limits guide's resource accounting, the network policies guide's protection for the shipping path, and the security-basics guide's audit and redaction rules. Get it right and it disappears — every other guide in this series quietly assumes the logs were there when needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fluent Bit or Fluentd?&lt;/strong&gt;&lt;br&gt;
For a new cluster pipeline, Fluent Bit: lighter, faster, and the current centre of gravity for Kubernetes log collection. Fluentd remains reasonable where its plugin ecosystem or an existing deployment carries weight. The architecture matters more than the choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loki or Elasticsearch?&lt;/strong&gt;&lt;br&gt;
If your queries are service-and-time-window shaped (most incident work), Loki's label model is dramatically cheaper to run. If you need deep, arbitrary full-text analytics over long retention, Elasticsearch/OpenSearch earns its cost. Many clusters start on their cloud provider's sink and graduate to one of these when query needs or bills demand it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should the application ever ship logs directly to the backend?&lt;/strong&gt;&lt;br&gt;
Almost never. It couples application deploys to logging infrastructure, duplicates shipping logic in every service, and loses the node agent's uniform metadata and buffering. The stdout contract exists precisely to avoid this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long should logs be retained?&lt;/strong&gt;&lt;br&gt;
Long enough to cover your slowest investigation and your compliance floor — commonly 2–4 weeks hot and a few months in cheap archive for application logs, longer for audit logs under policy. Decide per class of log, write it down, and let the tiers enforce it automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do managed services (EKS, GKE, AKS) solve this for me?&lt;/strong&gt;&lt;br&gt;
They provide a destination (CloudWatch, Cloud Logging, Azure Monitor) and sometimes an agent option, but the architecture decisions remain yours: stdout discipline, metadata, correlation IDs, retention, and cost control. The provider removes the backend operations, not the design.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>logging</category>
      <category>observability</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>GitHub Actions to AWS Without Stored Keys: OIDC Federation in 2026</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:00:10 +0000</pubDate>
      <link>https://dev.to/softverseadmin/github-actions-to-aws-without-stored-keys-oidc-federation-in-2026-14o9</link>
      <guid>https://dev.to/softverseadmin/github-actions-to-aws-without-stored-keys-oidc-federation-in-2026-14o9</guid>
      <description>&lt;h1&gt;
  
  
  GitHub Actions to AWS Without Stored Keys: OIDC Federation in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; If your GitHub Actions workflows authenticate to AWS with a stored access key, that key is a permanent credential sitting in a second system, waiting to leak and rarely rotated. OIDC federation removes it entirely: each workflow run gets a short-lived token from GitHub, presents it to AWS, and assumes an IAM role scoped to exactly that repository, branch, or environment. Credentials live for about an hour and are never stored anywhere. Setup is one IAM identity provider, one role with a careful trust policy, and two lines in the workflow. This guide walks the whole thing, including the scoping mistakes that quietly undo the benefit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/github-actions-aws-oidc-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a permanent key in someone else's house
&lt;/h2&gt;

&lt;p&gt;The classic setup is familiar: create an IAM user for CI, generate an access key pair, paste them into GitHub repository secrets, and reference them in the workflow. It works on day one. Then the years happen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The key &lt;strong&gt;never rotates&lt;/strong&gt;, because rotating it means touching every repo that copied it — and nobody remembers all of them.&lt;/li&gt;
&lt;li&gt;The key &lt;strong&gt;outlives its purpose&lt;/strong&gt;: the project is archived, the repo is forked into a test, the contractor who set it up left. The credential remains valid through all of it.&lt;/li&gt;
&lt;li&gt;The key's &lt;strong&gt;permissions drift upward&lt;/strong&gt;: it was scoped for one deployment, then someone needed one more action, then it quietly became near-admin because that was faster.&lt;/li&gt;
&lt;li&gt;The key &lt;strong&gt;leaks like a secret, not like a session&lt;/strong&gt;: pasted into a log, a debug dump, a screenshot. When a session token leaks you have an hour of exposure; when this leaks you have until someone notices and rotates — if anyone notices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deeper issue is architectural: a long-lived credential is a standing invitation. OIDC federation deletes the standing part. There is nothing to steal from the repository because the repository holds nothing — the workflow &lt;em&gt;proves who it is&lt;/em&gt; at runtime instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works, in one paragraph
&lt;/h2&gt;

&lt;p&gt;GitHub runs an OIDC identity provider. Every workflow run can request a signed JSON Web Token whose claims describe that run: the repository (&lt;code&gt;sub&lt;/code&gt;ject claims like &lt;code&gt;repo:my-org/my-repo:ref:refs/heads/main&lt;/code&gt;), the branch or tag, the environment, the workflow name. In AWS, you register GitHub as an IAM OIDC provider once per account, then create an IAM role whose &lt;strong&gt;trust policy&lt;/strong&gt; says: "tokens from GitHub's provider may assume me — but only if the claims match these exact conditions." The workflow exchanges its token via &lt;code&gt;AssumeRoleWithWebIdentity&lt;/code&gt; and receives ordinary short-lived AWS credentials (default lifetime one hour). AWS validates the token's signature against GitHub's published keys and checks the audience claim (&lt;code&gt;sts.amazonaws.com&lt;/code&gt;) and your conditions. No shared secret exists on either side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: register GitHub as an OIDC provider in IAM
&lt;/h2&gt;

&lt;p&gt;One time per AWS account (Console: IAM → Identity providers → Add provider → OpenID Connect):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provider URL:&lt;/strong&gt; &lt;code&gt;https://token.actions.githubusercontent.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audience:&lt;/strong&gt; &lt;code&gt;sts.amazonaws.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the entire provider setup. Everything else lives on the role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the role and its trust policy — where the security actually is
&lt;/h2&gt;

&lt;p&gt;Create an IAM role for web identity federation. The trust policy is the security boundary of this whole design, so write it deliberately:&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&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;"Federated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"&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;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&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;"StringEquals"&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;"token.actions.githubusercontent.com:aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&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;"StringLike"&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;"token.actions.githubusercontent.com:sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"repo:my-org/my-repo:ref:refs/heads/main"&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;What each part earns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;aud&lt;/code&gt; condition&lt;/strong&gt; confirms the token was minted for AWS STS, not for some other service that also trusts GitHub tokens.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;sub&lt;/code&gt; condition&lt;/strong&gt; is the scoping decision. &lt;code&gt;repo:my-org/my-repo:ref:refs/heads/main&lt;/code&gt; means: only runs of this repo, on the main branch. Alternatives you will actually use:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;repo:my-org/my-repo:environment:production&lt;/code&gt; — only runs that deploy to the GitHub &lt;em&gt;environment&lt;/em&gt; named production (which can itself require manual approval — a strong pattern for prod).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;repo:my-org/my-repo:ref:refs/tags/v*&lt;/code&gt; with &lt;code&gt;StringLike&lt;/code&gt; — only tag pushes, for release roles.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; a wildcard like &lt;code&gt;repo:my-org/*&lt;/code&gt; on a powerful role, and never omit the &lt;code&gt;sub&lt;/code&gt; condition. "Any workflow in any repo I own can assume the production deploy role" is the stored-key problem wearing a new coat.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then attach the role's &lt;strong&gt;permissions policy&lt;/strong&gt; — the least-privilege part. Scope it to what the pipeline does: update one ECS service, write to one S3 bucket, publish to one ECR repository. The AWS IAM least-privilege guide on this site covers how to build such policies without guessing; the OIDC work here assumes that discipline rather than replacing it.&lt;/p&gt;

&lt;p&gt;One role per (repository, environment) pair is the pattern that scales: &lt;code&gt;gha-myrepo-prod&lt;/code&gt;, &lt;code&gt;gha-myrepo-staging&lt;/code&gt;, each boringly narrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the workflow side
&lt;/h2&gt;

&lt;p&gt;Two requirements, both small:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;      &lt;span class="c1"&gt;# without this, no token is issued&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS credentials&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/gha-myrepo-prod&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ap-northeast-1&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;aws s3 sync ./dist s3://my-prod-site-bucket --delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes that save debugging time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;permissions: id-token: write&lt;/code&gt; is the step everyone forgets.&lt;/strong&gt; Without it the token request fails before AWS is even involved. Setting any &lt;code&gt;permissions&lt;/code&gt; block also means everything not listed drops to none — hence &lt;code&gt;contents: read&lt;/code&gt; for checkout.&lt;/li&gt;
&lt;li&gt;There are &lt;strong&gt;no secrets to configure&lt;/strong&gt;: &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and friends disappear from the repo settings entirely.&lt;/li&gt;
&lt;li&gt;The configure action exports the short-lived credentials to subsequent steps; tools that read the standard AWS credential chain (the AWS CLI, Terraform, SDKs) work unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scoping patterns that matter
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Environments are the strongest lever.&lt;/strong&gt; GitHub environments can require a human approval before a job runs, and the token's claims carry the environment name. Combine them: the production role trusts only &lt;code&gt;environment:production&lt;/code&gt;, so even a maintainer pushing to main cannot reach prod credentials until the environment's approval gate passes. Branch protection plus environment approval plus a scoped role is about as strong as CI identity gets without extra tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pull requests are a different animal.&lt;/strong&gt; A &lt;code&gt;pull_request&lt;/code&gt; run's claims do not look like a branch push — and for forks, the workflow runs in a context you should treat as untrusted. Practical rule: give PR workflows a read-only role if they need AWS at all (fetching artifacts, running plan), and never let a PR-triggered run assume a role that can write to production. &lt;code&gt;terraform plan&lt;/code&gt; on PRs with a read-only role, &lt;code&gt;apply&lt;/code&gt; only from main with the production role, is the canonical split — it pairs naturally with the Terraform state setup from this site's state-management guide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple accounts, one pattern.&lt;/strong&gt; Staging and production in separate AWS accounts is common; OIDC handles it cleanly because the provider registration and role live per account. The same workflow can assume the staging role in the staging account and the production role in the production account, each trust policy scoped to its own environment claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reusable workflows inherit the caller's identity carefully.&lt;/strong&gt; If you use reusable workflows (this site's guide covers them), the token claims describe the &lt;em&gt;caller&lt;/em&gt; repository and context — which is what you want, but it means a shared deploy workflow used by ten repos needs trust-policy entries or separate roles per caller repo. Plan for that instead of discovering it as a wildcard temptation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating off stored keys without breaking Friday's deploy
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory.&lt;/strong&gt; Search your repos' secrets and workflows for &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;. Note which IAM user each key belongs to and what it can do — this list is usually longer and scarier than expected, which is the argument for the project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create the provider and roles&lt;/strong&gt;, one repo/environment at a time, permissions mirroring what the key was &lt;em&gt;supposed&lt;/em&gt; to do (not what it accidentally can).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switch the workflow&lt;/strong&gt; to the OIDC pattern and deploy. Verify in CloudTrail that calls now arrive as the assumed role session, not the old user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch, then delete.&lt;/strong&gt; Leave the old key deactivated (not deleted) for a short window while you confirm nothing else uses it — scheduled jobs and forgotten repos surface quickly when a key stops working. Then delete the key, and if the CI IAM user has no remaining purpose, delete the user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sweep the leftovers:&lt;/strong&gt; organisation-level secrets, third-party tools holding the same keys, and local copies in developer tooling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Order matters less than completeness: a migration that moves nine repos and forgets the tenth has kept exactly the risk it meant to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wildcard &lt;code&gt;sub&lt;/code&gt; conditions.&lt;/strong&gt; &lt;code&gt;repo:my-org/*&lt;/code&gt; or a missing &lt;code&gt;sub&lt;/code&gt; check turns one role into every repo's master key. The trust policy is the product; write it narrowly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One role for everything.&lt;/strong&gt; A single powerful role assumed by every workflow recreates the shared-key blast radius with better marketing. Role per repo per environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;id-token: write&lt;/code&gt;.&lt;/strong&gt; The failure appears before AWS is contacted and confuses everyone who starts debugging in IAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving the old keys active "just in case".&lt;/strong&gt; The fallback &lt;em&gt;is&lt;/em&gt; the vulnerability. Deactivate, watch, delete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granting write roles to pull-request contexts.&lt;/strong&gt; Plan on PRs, apply from protected branches and environments — never both from the same trigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the audience check.&lt;/strong&gt; The &lt;code&gt;aud&lt;/code&gt; condition costs one line and prevents tokens minted for other purposes from being replayed at STS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming OIDC fixes permissions.&lt;/strong&gt; Federation decides &lt;em&gt;who&lt;/em&gt; may assume the role, not &lt;em&gt;what&lt;/em&gt; the role may do. An admin role via OIDC is still an admin role; least privilege is a separate, continuing discipline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this cost anything?&lt;/strong&gt;&lt;br&gt;
No. OIDC providers and roles are free IAM features, and STS calls for this pattern are not a line item you will ever notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if a workflow needs to run longer than the token lifetime?&lt;/strong&gt;&lt;br&gt;
Role sessions default to one hour and can be configured up to the role's maximum session duration (up to 12 hours). Design for re-assumption or shorter jobs instead of reaching for the maximum by default — long-lived sessions quietly reintroduce the exposure you removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can self-hosted runners use this?&lt;/strong&gt;&lt;br&gt;
Yes — runners you host still request the token from GitHub's OIDC endpoint at runtime, so the same trust policy works. (GitHub Enterprise Server deployments use their own provider URL; check your instance's docs for its exact value.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Terraform in GitHub Actions work with OIDC credentials?&lt;/strong&gt;&lt;br&gt;
Yes. The configure-aws-credentials step exports standard environment credentials, and the Terraform AWS provider consumes them through its normal credential chain — no provider block changes needed. Remote state locking and the backend setup stay exactly as they were.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about other clouds?&lt;/strong&gt;&lt;br&gt;
The same pattern exists across the major providers — Azure and Google Cloud both accept GitHub-issued OIDC tokens for workload identity federation with no stored secrets. Once a team learns the shape on one cloud, the others are variations, and the destination of all of them is the same: CI with no permanent keys anywhere.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Kubernetes Pod Disruption Budgets in 2026: Graceful Shutdown Without Dropped Requests</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 10:51:44 +0000</pubDate>
      <link>https://dev.to/softverseadmin/kubernetes-pod-disruption-budgets-in-2026-graceful-shutdown-without-dropped-requests-nig</link>
      <guid>https://dev.to/softverseadmin/kubernetes-pod-disruption-budgets-in-2026-graceful-shutdown-without-dropped-requests-nig</guid>
      <description>&lt;h1&gt;
  
  
  Kubernetes Pod Disruption Budgets in 2026: Graceful Shutdown Without Dropped Requests
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Kubernetes constantly disrupts your pods on purpose — node drains, cluster upgrades, autoscaler scale-downs, rollouts. Pod Disruption Budgets (PDBs) are how you negotiate those disruptions: "you may take one of my three replicas at a time, never two." But a PDB only covers the eviction handshake. The other half of graceful shutdown is the pod itself: handling SIGTERM, finishing in-flight work, and leaving the load balancer before the SIGKILL lands. Get both halves right and maintenance stops being an outage source; get either wrong and every upgrade is a small incident.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/kubernetes-pod-disruption-budgets-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: most pod deaths are voluntary
&lt;/h2&gt;

&lt;p&gt;When people imagine pods dying, they imagine failures — a node dying, an OOM kill, a crash. Those are &lt;em&gt;involuntary&lt;/em&gt; disruptions, and Kubernetes handles them the only way it can: after the fact, by starting replacements.&lt;/p&gt;

&lt;p&gt;But watch a healthy cluster for a week and most pod terminations are &lt;em&gt;voluntary&lt;/em&gt;: an admin drains a node for maintenance, the cluster autoscaler removes an underused node, an upgrade rolls the nodes, a deployment replaces pods, the cluster itself gets upgraded version by version (the upgrades guide in this series is essentially a long, careful series of voluntary disruptions). Nothing failed — the platform is doing its job.&lt;/p&gt;

&lt;p&gt;The trouble is the intersection: a drain that evicts all three replicas of your API in the same minute because nobody told it not to. A pod killed mid-request because it never got the memo — or got it and ignored it. Voluntary disruptions are scheduled, polite, and negotiable. You just have to actually negotiate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pod Disruption Budgets: the negotiation
&lt;/h2&gt;

&lt;p&gt;A PDB is a small object that tells the eviction API how many pods of a workload may be voluntarily taken down at once:&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;policy/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;PodDisruptionBudget&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-pdb&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;minAvailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This says: of the pods labelled &lt;code&gt;app: api&lt;/code&gt; in &lt;code&gt;shop&lt;/code&gt;, at least 2 must stay available; evictions that would break that are refused with an error, and the drain waits. Two ways to express the budget:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;minAvailable&lt;/code&gt;&lt;/strong&gt; — an absolute number (&lt;code&gt;2&lt;/code&gt;) or a percentage (&lt;code&gt;67%&lt;/code&gt;) of desired replicas that must remain up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maxUnavailable&lt;/code&gt;&lt;/strong&gt; — how many may be down at once (&lt;code&gt;1&lt;/code&gt;, or &lt;code&gt;34%&lt;/code&gt;). Usually the better choice, because it tracks replica count: if you scale from 3 to 6 replicas, "at most 1 down" still means what you meant, while a stale &lt;code&gt;minAvailable: 2&lt;/code&gt; silently got weaker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rounding is asymmetric and worth knowing: percentages for &lt;code&gt;minAvailable&lt;/code&gt; round &lt;strong&gt;up&lt;/strong&gt; (67% of 3 = 3, not 2), and for &lt;code&gt;maxUnavailable&lt;/code&gt; round &lt;strong&gt;down&lt;/strong&gt; (34% of 3 = 1). If your PDB seems stricter than the percentage suggests, this is why.&lt;/p&gt;

&lt;p&gt;How the handshake works in practice: &lt;code&gt;kubectl drain&lt;/code&gt;, cluster upgrades, and well-behaved controllers evict pods through the &lt;strong&gt;eviction API&lt;/strong&gt;, which checks the PDB first. If the budget allows it, the pod gets its termination grace period and shuts down; if not, the eviction is rejected and the tool retries later. Tools that delete pods directly (a raw &lt;code&gt;kubectl delete pod&lt;/code&gt;) bypass PDBs entirely — a PDB is a contract with polite tooling, not a force field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: a PDB that can never be satisfied
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;minAvailable&lt;/code&gt; equal to your replica count — a very common "extra safe" instinct — and you have built a deadlock, not a safeguard. The node can never drain, because draining requires evicting a pod, and the budget forbids it. Cluster upgrades stall, autoscaler scale-downs stall, and someone eventually force-deletes the pod at the worst possible time, defeating the entire exercise under pressure.&lt;/p&gt;

&lt;p&gt;The subtler version: the budget is satisfiable on paper, but the workload is unhealthy. If only 1 of 3 replicas is ready, a &lt;code&gt;minAvailable: 2&lt;/code&gt; PDB blocks &lt;em&gt;all&lt;/em&gt; evictions — including evicting the broken pods — because the budget counts healthy pods, and there is no slack. Recent Kubernetes versions address this with &lt;code&gt;spec.unhealthyPodEvictionPolicy&lt;/code&gt; (set it to &lt;code&gt;AlwaysAllow&lt;/code&gt; so unhealthy pods can always be evicted; the default &lt;code&gt;IfHealthyBudget&lt;/code&gt; is stricter). A blocked drain is information: it usually means the application was already below its own availability floor before maintenance started.&lt;/p&gt;

&lt;p&gt;A working rule: &lt;strong&gt;budget for one disruption at a time.&lt;/strong&gt; &lt;code&gt;maxUnavailable: 1&lt;/code&gt; for replicated stateless services is the sensible default — drains proceed pod by pod, replacements come up before the next eviction is allowed, and upgrades stay boring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: what the pod does when told to die
&lt;/h2&gt;

&lt;p&gt;A PDB buys an orderly &lt;em&gt;sequence&lt;/em&gt;. It does not make an individual shutdown graceful. That part happens inside the pod, on a clock:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The pod is marked &lt;code&gt;Terminating&lt;/code&gt;. It is removed from Service endpoints — mostly. Endpoint removal and the kube-proxy / ingress updates that follow are fast but not instantaneous, so a terminating pod can receive a trickle of new connections for a short window. This race is normal and known.&lt;/li&gt;
&lt;li&gt;Any &lt;code&gt;preStop&lt;/code&gt; lifecycle hook runs.&lt;/li&gt;
&lt;li&gt;The container receives &lt;strong&gt;SIGTERM&lt;/strong&gt;. This is the application's cue: stop accepting new work, finish in-flight requests, flush and close.&lt;/li&gt;
&lt;li&gt;After &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; (default 30), if the process is still alive, it receives &lt;strong&gt;SIGKILL&lt;/strong&gt; — no negotiation, mid-whatever.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Graceful shutdown is an application feature, not a platform one. The checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Handle SIGTERM in the process.&lt;/strong&gt; Many frameworks do this out of the box now (Spring Boot's graceful shutdown, Go's &lt;code&gt;signal.NotifyContext&lt;/code&gt;, Node's server close on signal) — but verify yours does, and that the handler &lt;em&gt;drains&lt;/em&gt;: stop taking requests, wait for in-flight ones, close database connections cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size the grace period to the work.&lt;/strong&gt; A web API that finishes requests in 2 seconds is fine at 30s. A worker with 5-minute jobs at the default 30s grace period gets SIGKILLed mid-job every rollout — set &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; to match the longest unit of work you intend to finish, and make the work checkpoint or idempotent for the cases where it still gets cut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;preStop&lt;/code&gt; sleep, honestly labelled.&lt;/strong&gt; A common pattern is a preStop hook that sleeps a few seconds, giving endpoint removal time to propagate before SIGTERM makes the app stop listening. It looks like a hack because it is one — but it addresses the real endpoint race in step 1, and it prevents the classic "502s during every deploy" bug. Use it knowingly, keep it short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers and queues:&lt;/strong&gt; on SIGTERM, stop &lt;em&gt;consuming&lt;/em&gt;, finish the current message, then exit — and make sure unprocessed messages return to the queue (visibility timeout / ack discipline) rather than dying with the pod.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stateful and single-replica workloads: where PDBs can't save you
&lt;/h2&gt;

&lt;p&gt;PDBs assume redundancy. Apply the arithmetic to a single-replica workload and &lt;code&gt;minAvailable: 1&lt;/code&gt; (or &lt;code&gt;maxUnavailable: 0&lt;/code&gt;) means "never voluntarily evict this pod, ever" — the drain deadlocks on your one database pod, and the eventual forced eviction takes the downtime anyway, unscheduled. For singletons, the honest choices are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accept the disruption window&lt;/strong&gt;: no PDB (or &lt;code&gt;maxUnavailable: 1&lt;/code&gt;), maintenance takes the pod down briefly, planned and announced. Better a scheduled 60-second blip than a blocked upgrade escalating into an unplanned one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make it not a singleton&lt;/strong&gt; if the availability requirement is real — which for databases means the database's own replication story, not a Kubernetes trick.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For StatefulSets, PDBs work normally (they select pods by label like anything else), but pair them with the storage and backup thinking from earlier in this series: an evicted stateful pod comes back to its own volume, so the shutdown risk is about in-flight writes — the application-level SIGTERM handling matters more, not less.&lt;/p&gt;

&lt;p&gt;One more placement rule: &lt;strong&gt;do not put PDBs on batch Jobs.&lt;/strong&gt; A Job's pods are supposed to terminate; a budget on them protects nothing and can wedge completions and node drains alike. PDBs are for long-running services whose absence a user would notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it: test the drain before the upgrade does
&lt;/h2&gt;

&lt;p&gt;Like every control in this series, a PDB is a rumour until exercised. The test fits in a staging afternoon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run a light, continuous request loop against the service (a small script recording status codes is enough).&lt;/li&gt;
&lt;li&gt;Cordon and drain a node running the workload: &lt;code&gt;kubectl cordon &amp;lt;node&amp;gt;&lt;/code&gt; then &lt;code&gt;kubectl drain &amp;lt;node&amp;gt; --ignore-daemonsets&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Watch: evictions should proceed one at a time, the replacement should be Ready before the next eviction is allowed, and the request loop should show zero — or near-zero — failed requests. Failed requests with graceful PDB behaviour usually point at the application half: SIGTERM ignored, grace period too short, or the endpoint race.&lt;/li&gt;
&lt;li&gt;Then test the &lt;em&gt;blocked&lt;/em&gt; path deliberately: scale the service down to its floor and try another eviction. Being refused, and seeing the clear PDB error, is the control working. An upgrade night is the wrong time to learn this behaviour for the first time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cluster autoscaler respects the same handshake when it scales nodes down (covered in the autoscaling guide in this series) — which is why a too-strict PDB shows up twice in a cluster's life: once blocking your upgrade, once blocking your cost savings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;minAvailable&lt;/code&gt; equal to the replica count.&lt;/strong&gt; The deadlock special. Budget one disruption at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Percentages that don't mean what you think.&lt;/strong&gt; &lt;code&gt;minAvailable: 50%&lt;/code&gt; of 3 replicas is 2 pods (it rounds up) — and of 1 replica it is a permanent eviction ban.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting PDBs to cover failures.&lt;/strong&gt; They govern voluntary evictions only. Node death, crashes and OOM kills are answered by replicas, topology spread and anti-affinity — the deployment-strategies guide covers that side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default 30-second grace for slow work.&lt;/strong&gt; Long requests, report generation, queue jobs: if the work takes minutes, the grace period must too, or every rollout is a small data-loss event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring SIGTERM "because Kubernetes restarts us anyway".&lt;/strong&gt; It does — elsewhere. The in-flight request that died with the old pod does not get restarted; the client just sees an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDBs on everything, including Jobs and singletons.&lt;/strong&gt; Indiscriminate budgets create blocked operations across the cluster and train the team to force-delete — the worst possible outcome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never alerting on blocked evictions.&lt;/strong&gt; A drain stuck for hours is a signal the app is below its availability floor. It belongs in the observability stack (the observability guide), not in tomorrow's surprise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this fits in the series
&lt;/h2&gt;

&lt;p&gt;PDBs are the politeness protocol that several earlier guides quietly assume: the upgrades guide's node-by-node dance only works when evictions are budgeted, the autoscaling guide's cluster autoscaler asks PDB permission before removing nodes, Argo Rollouts and the deployment-strategies guide manage the same risk from the release side, and the backup guide is the admission that even perfect politeness has limits. Voluntary disruption done well is invisible — which is precisely the point of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does a Pod Disruption Budget protect against node failure?&lt;/strong&gt;&lt;br&gt;
No. PDBs only constrain voluntary evictions through the eviction API. Involuntary loss — node death, kernel panic, a cloud reclaiming the instance — ignores budgets entirely. Redundancy (replicas, topology spread constraints, anti-affinity) is the tool for that side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use minAvailable or maxUnavailable?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;maxUnavailable&lt;/code&gt; in most cases, typically &lt;code&gt;1&lt;/code&gt;. It states the disruption you tolerate rather than a floor that silently weakens or deadlocks as replica counts change. Reserve &lt;code&gt;minAvailable&lt;/code&gt; for workloads whose floor is a hard contractual number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a PDB block an upgrade forever?&lt;/strong&gt;&lt;br&gt;
Yes, if the budget can never be met — either by construction (floor equals replicas) or because the workload is too unhealthy to have slack. That is the PDB doing its stated job; the fix is to repair the workload's health or consciously relax the budget, not to force-delete pods under pressure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do Deployments create PDBs automatically?&lt;/strong&gt;&lt;br&gt;
No. A Deployment manages replicas; the budget is a separate object you write, select, and own. Some Helm charts ship one (check before assuming — the value may not match your replica count), and the Helm guide in this series shows where such templates live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to in-flight requests when a pod is evicted?&lt;/strong&gt;&lt;br&gt;
The pod leaves the Service endpoints, gets SIGTERM, and has its grace period to finish. Requests still in flight when the grace period expires die with the SIGKILL. Both halves of this guide exist to shrink that window to zero: PDBs sequence the evictions, and the application's shutdown handling empties the pod before the deadline.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>reliability</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Kubernetes Backup and Disaster Recovery in 2026: Backups You Have Actually Restored</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 10:43:19 +0000</pubDate>
      <link>https://dev.to/softverseadmin/kubernetes-backup-and-disaster-recovery-in-2026-backups-you-have-actually-restored-1d82</link>
      <guid>https://dev.to/softverseadmin/kubernetes-backup-and-disaster-recovery-in-2026-backups-you-have-actually-restored-1d82</guid>
      <description>&lt;h1&gt;
  
  
  Kubernetes Backup and Disaster Recovery in 2026: Backups You Have Actually Restored
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; High availability is not backup. A three-replica Deployment survives a node failure, but &lt;code&gt;kubectl delete namespace&lt;/code&gt; deletes all three replicas just as happily, and no amount of HA brings back a dropped database. Real protection has three layers — cluster resources, persistent volume data, and the things that live outside the cluster — plus a restore you have actually performed and timed. Velero is the standard open-source tool for the first two layers. The work is not installing it; the work is proving, on a schedule, that a restore produces a working application.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/kubernetes-backup-disaster-recovery-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: Kubernetes feels safer than it is
&lt;/h2&gt;

&lt;p&gt;Kubernetes heals. Kill a pod and it comes back. Lose a node and workloads reschedule. This self-healing is real — and it quietly convinces teams they are protected against data loss, which is a different thing entirely.&lt;/p&gt;

&lt;p&gt;Self-healing protects against &lt;em&gt;failures of the machinery&lt;/em&gt;. Backups protect against &lt;em&gt;everything else&lt;/em&gt;: an engineer deleting the wrong namespace, a bad migration dropping a table, a compromised account wiping resources, a storage failure corrupting a volume, a cloud account problem taking the whole region's control plane with it. In every one of those cases, the cluster faithfully heals itself into the new, broken state. The disaster is not that Kubernetes stops working — it is that it keeps working, on empty data.&lt;/p&gt;

&lt;p&gt;The second misconception: "etcd is backed up, so we are covered." On a self-managed cluster, an etcd snapshot captures cluster state — the resource definitions. It does not contain the bytes inside your PersistentVolumes, and restoring it is a cluster surgery procedure, not an application recovery. On managed services (EKS, GKE, AKS) you cannot snapshot etcd at all — the provider runs it — which settles the question: your backup strategy lives at the resource and volume level whether you chose it or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually needs protecting: three layers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cluster resources.&lt;/strong&gt; Deployments, Services, ConfigMaps, Secrets, RBAC, NetworkPolicies — the declarative state. Cheap to back up (it is text in etcd), and the layer GitOps already half-covers: if everything is applied from Git, Git is a backup of intent, though not of generated state like Secrets created by operators or resources created by controllers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent data.&lt;/strong&gt; The contents of PersistentVolumes: databases, uploaded files, queues on disk. This is the layer that hurts to lose and the layer most "we ran a backup tool once" setups quietly miss, because resource backup and volume backup are separate switches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outside the cluster.&lt;/strong&gt; Container images (a registry that garbage-collects the tag you need is a DR failure), external databases and queues, DNS, certificates, and the cloud resources the cluster depends on — IAM roles, load balancer config, the object storage bucket your backups land in. A restore plan that discovers a missing layer at 3 a.m. is not a plan.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Backups also need their own threat model: they must survive the loss of the cluster &lt;em&gt;and&lt;/em&gt; the loss of the account or credentials that administer it. Backup data stored only in the same account, deletable by the same compromised credential, is a copy, not a backup. Cross-account or cross-region copies with separate retention are the grown-up version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two numbers that decide the design: RPO and RTO
&lt;/h2&gt;

&lt;p&gt;Before tooling, two definitions do all the real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RPO (recovery point objective):&lt;/strong&gt; how much data you can afford to lose, measured in time. Hourly backups mean up to an hour of data loss. If the business answer is "five minutes", nightly volume snapshots are the wrong tool no matter how reliable they are — you need replication or database-native continuous backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RTO (recovery time objective):&lt;/strong&gt; how long the business tolerates being down while you restore. This is the number restore tests measure, and it includes everything: provisioning a cluster, restoring resources, restoring data, DNS, verification. Teams routinely discover their restore takes six hours against a one-hour RTO — better discovered in a drill than in an incident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write both numbers down per application tier. They turn backup arguments ("should we also snapshot the volumes?") into arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1 + 2 with Velero: the working setup
&lt;/h2&gt;

&lt;p&gt;Velero is the standard open-source tool for Kubernetes backup: it copies cluster resources to object storage and, when asked, snapshots or copies the data inside PersistentVolumes. Two facts shape every Velero setup: backups land in &lt;strong&gt;object storage outside the cluster&lt;/strong&gt; (S3, GCS, Azure Blob, or S3-compatible), and volume data needs an explicit choice — it is not automatic.&lt;/p&gt;

&lt;p&gt;Install against your provider's plugin (AWS shown; bucket and credentials are placeholders):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;velero &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--provider&lt;/span&gt; aws &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--plugins&lt;/span&gt; velero/velero-plugin-for-aws &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-cluster-backups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--secret-file&lt;/span&gt; ./credentials-velero &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--backup-location-config&lt;/span&gt; &lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ap-northeast-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--use-node-agent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--use-node-agent&lt;/code&gt; deploys the agent that performs file-system-level volume backups — needed for the second volume option below.&lt;/p&gt;

&lt;p&gt;There are two ways Velero captures volume data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSI snapshots&lt;/strong&gt; — the storage driver snapshots the volume (on EKS, an EBS snapshot). Fast, storage-native, and the right default where the CSI driver supports snapshots. Since Velero 1.15 the CSI integration is built in rather than a separate plugin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File-system backup&lt;/strong&gt; — Velero copies the files out of the volume through the node agent (Kopia under the hood in current releases). Slower, but works on storage with no snapshot support and produces data that lives in the same object storage as the resource backup, which simplifies cross-cluster restores.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A scheduled backup of one namespace, with volume snapshots, is one object:&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;velero.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Schedule&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;shop-daily&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;velero&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;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;includedNamespaces&lt;/span&gt;&lt;span class="pi"&gt;:&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;snapshotVolumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;720h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the manual equivalents you will use in drills:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;velero backup create shop-drill &lt;span class="nt"&gt;--include-namespaces&lt;/span&gt; shop &lt;span class="nt"&gt;--snapshot-volumes&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt;
velero restore create &lt;span class="nt"&gt;--from-backup&lt;/span&gt; shop-drill &lt;span class="nt"&gt;--namespace-mappings&lt;/span&gt; shop:shop-restore
velero backup describe shop-drill      &lt;span class="c"&gt;# check phase: Completed, and read the warnings&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restoring into a &lt;em&gt;different&lt;/em&gt; namespace (the &lt;code&gt;--namespace-mappings&lt;/code&gt; above) is the drill pattern: the original workload keeps running while you prove the copy works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consistency problem: volumes are crash copies
&lt;/h2&gt;

&lt;p&gt;A volume snapshot taken under a running database is a crash-consistent copy — equivalent to pulling the plug. Most databases recover from that on startup, which is why the pattern usually works, but "usually" is doing heavy lifting. For the workloads where consistency matters, you have three honest options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backup hooks.&lt;/strong&gt; Velero can run a command in a pod before or after the snapshot (annotations on the pod template) — the classic pattern is freezing or flushing the database (for example &lt;code&gt;fsfreeze&lt;/code&gt; or a database-specific lock) for the seconds the snapshot takes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database-native backup.&lt;/strong&gt; For a critical PostgreSQL or MySQL, the serious answer is often the database's own tooling — WAL archiving / point-in-time recovery — with Velero protecting the manifests and configuration around it. Point-in-time recovery also beats any snapshot schedule on RPO.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quiesce the workload.&lt;/strong&gt; Scale to zero, snapshot, scale back. Only acceptable where a maintenance window exists — but for small internal systems it is the simplest correct answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick per workload, and write the choice down next to the RPO it achieves. The failure mode is assuming the default (crash-consistent) covers the database it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GitOps shortcut, and its limits
&lt;/h2&gt;

&lt;p&gt;If your manifests live in Git and Argo CD (or equivalent) applies them, layer 1 mostly restores itself: point a new cluster at the repo and the workloads reappear. This is a genuine DR advantage of GitOps — the Argo CD guide in this series covers the setup. But know exactly what Git does &lt;em&gt;not&lt;/em&gt; contain: Secrets (usually stored encrypted or externally — your restore needs the key material, e.g. the Sealed Secrets private key, or the external secret store), data created inside the cluster, and resources generated by operators. The standard production answer is both: GitOps for the declarative layer, Velero for the generated state and the volume data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it: restore drills are the actual product
&lt;/h2&gt;

&lt;p&gt;Every guide in this series lands on the same principle — an untested control is a rumour — and backups are where it matters most, because backup failures are silent. Velero will happily report schedules running while the bucket policy expired months ago. The only verification that counts is a restore.&lt;/p&gt;

&lt;p&gt;A workable drill rhythm:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Monthly:&lt;/strong&gt; restore last night's backup of one real namespace into a scratch namespace on the same cluster. Verify the application answers, data row counts match expectations, and record how long it took — that number is your honest RTO for that tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quarterly:&lt;/strong&gt; restore into a &lt;em&gt;different&lt;/em&gt; cluster (the migration path). This catches the things intra-cluster restores never touch: missing CRDs, storage class names that differ, image pull secrets, ingress differences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always:&lt;/strong&gt; alert on backup failure, not just on schedule age. Wire backup status into the same observability stack as everything else — the observability guide in this series is where the backup metrics and alerts belong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what the drill produces beyond safety: a measured restore time, a list of missing pieces fixed while nothing was on fire, and a team that has performed the procedure before the night it counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backing up resources and calling volumes done.&lt;/strong&gt; &lt;code&gt;snapshotVolumes&lt;/code&gt; off (or no file-system backup configured) means your backup is the recipe, not the meal. Check a backup's contents, not just its green status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups in the blast radius.&lt;/strong&gt; Same account, same credentials, no versioning or retention lock on the bucket: one compromise deletes production and its backups in the same session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No TTL / no cost control.&lt;/strong&gt; Snapshots accumulate forever; the storage guide in this series covers the orphaned-snapshot problem from the cost side. Every schedule needs a &lt;code&gt;ttl&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets that cannot be restored.&lt;/strong&gt; The backup contains the Sealed Secrets resources but the cluster that sealed them is the one that died. Keep the unsealing keys in the DR plan, stored outside the cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restoring everything at once.&lt;/strong&gt; A full-cluster restore recreates namespaces in dependency order whether you plan it or not — and load balancers, ingress and DNS fight you on the way. Restore per-application, most critical first; that ordering &lt;em&gt;is&lt;/em&gt; the RTO plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One backup tool for the database it cannot help.&lt;/strong&gt; Crash-consistent volume copies of a busy database, with no hooks and no native backup, presented as a DR strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing backups by reading their status.&lt;/strong&gt; Green "Completed" is Velero's opinion. The restore is the test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this fits in the series
&lt;/h2&gt;

&lt;p&gt;Backup is the layer underneath several earlier guides: the storage guide explains the volumes being protected, the upgrades guide's blue/green cluster pattern is a restore-shaped procedure (and the quarterly cross-cluster drill doubles as upgrade rehearsal), the observability guide is where backup success and failure get monitored, and the network policies and security-basics guides cover the access control that keeps backup credentials and buckets out of an attacker's reach. Recovery is not a feature you add at the end; it is the property the rest of the stack is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does Velero back up etcd?&lt;/strong&gt;&lt;br&gt;
No — deliberately. Velero works at the API level, copying resources and volume data; etcd snapshots are a separate, control-plane procedure for self-managed clusters. Velero's model also restores into a cluster running a different Kubernetes version, which an etcd restore cannot do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We run GitOps. Do we still need Velero?&lt;/strong&gt;&lt;br&gt;
Yes, for the parts Git cannot see: volume data, generated resources, and in-cluster state. GitOps shrinks the restore problem; it does not remove it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often should backups run?&lt;/strong&gt;&lt;br&gt;
As often as your RPO demands, minus honesty about restore cost: a common shape is daily schedules with a 30-day TTL for general namespaces, more frequent (or database-native continuous backup) for the tier whose RPO is measured in minutes. More schedules than that mostly buy storage cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a second cluster in another region enough — active-active instead of backup?&lt;/strong&gt;&lt;br&gt;
Replication solves some RPO problems and no deletion problems: a replicated &lt;code&gt;kubectl delete&lt;/code&gt; is a deletion in two regions. Multi-cluster setups still need backups, and most teams should get backups plus practiced restores working before paying for a second always-on cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can Velero migrate between clusters?&lt;/strong&gt;&lt;br&gt;
Yes — backup on one cluster, restore on another is a first-class workflow, and the standard way to move workloads during a cluster rebuild or the blue/green upgrade pattern. That dual use is another reason the quarterly drill pays off: it rehearses two procedures at once.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>backup</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Kubernetes Network Policies in 2026: Lock Down Pod Traffic Without Breaking Your App</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 10:33:40 +0000</pubDate>
      <link>https://dev.to/softverseadmin/kubernetes-network-policies-in-2026-lock-down-pod-traffic-without-breaking-your-app-5afp</link>
      <guid>https://dev.to/softverseadmin/kubernetes-network-policies-in-2026-lock-down-pod-traffic-without-breaking-your-app-5afp</guid>
      <description>&lt;h1&gt;
  
  
  Kubernetes Network Policies in 2026: Lock Down Pod Traffic Without Breaking Your App
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; By default, every pod in Kubernetes can talk to every other pod — a flat network with no internal walls. Network Policies are the built-in way to change that: declare which pods may talk to which, on which ports, and everything else is denied. Start with a default-deny policy per namespace, add back exactly the flows your app needs (including DNS — everyone forgets DNS), and test with real connection attempts, not hope. One catch: your CNI plugin must actually enforce policies, or you are writing rules nobody obeys.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/kubernetes-network-policies-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a flat network inside the cluster
&lt;/h2&gt;

&lt;p&gt;Kubernetes networking guarantees that pods can reach each other. What it does not give you, out of the box, is any restriction on that reachability. A compromised frontend pod can scan the database. A batch job can reach the payment service. A pod in the dev namespace can talk to a pod in prod if they share a cluster.&lt;/p&gt;

&lt;p&gt;Network Policies fix this at the platform layer. They are firewall rules expressed as Kubernetes objects: selectors pick the pods a policy applies to, and rules list the allowed sources (ingress), destinations (egress), and ports. Anything not explicitly allowed is denied — but only once at least one policy selects a pod. A pod with no policy selecting it remains wide open, which is the single most misunderstood part of the whole feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the prerequisite nobody checks: your CNI
&lt;/h2&gt;

&lt;p&gt;NetworkPolicy is an API, not an implementation. The enforcement is done by your CNI plugin or network stack. Calico and Cilium enforce policies fully. Flannel, historically, does not enforce them at all — you can apply perfect policies and traffic flows anyway. On managed services it varies: Amazon's VPC CNI supports Network Policy enforcement in current EKS versions, GKE offers Dataplane V2 or Calico-backed policy, and AKS has Azure CNI powered by Cilium or its own policy manager options.&lt;/p&gt;

&lt;p&gt;Before writing a single rule, verify enforcement in a scratch namespace: apply a default-deny, try a connection that should fail, and watch it fail. A policy that has never been tested is a rumour, not a control. (The security-basics guide in this series covers where Network Policies sit among the other cluster controls.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The anatomy of a policy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&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-allow&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;podSelector&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;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Egress&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;podSelector&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;web&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;podSelector&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;postgres&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a sentence: &lt;em&gt;Pods labelled &lt;code&gt;app: api&lt;/code&gt; in namespace &lt;code&gt;shop&lt;/code&gt; may accept TCP 8080 from pods labelled &lt;code&gt;app: web&lt;/code&gt;, and may initiate TCP 5432 to pods labelled &lt;code&gt;app: postgres&lt;/code&gt;. Everything else for these pods is denied.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empty &lt;code&gt;podSelector: {}&lt;/code&gt; selects every pod in the namespace&lt;/strong&gt; — that is how you write a namespace-wide default-deny.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;policyTypes&lt;/code&gt; is a promise.&lt;/strong&gt; If you list &lt;code&gt;Egress&lt;/code&gt; but write no egress rules, all egress is denied. If you omit &lt;code&gt;Egress&lt;/code&gt; from &lt;code&gt;policyTypes&lt;/code&gt; while writing an ingress-only policy, egress stays open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;from&lt;/code&gt;/&lt;code&gt;to&lt;/code&gt; entries in one list item are OR'd; separate list items are also OR'd.&lt;/strong&gt; Within one item, a &lt;code&gt;podSelector&lt;/code&gt; and &lt;code&gt;namespaceSelector&lt;/code&gt; together are AND'd — the classic way to say "pods with this label, in namespaces with that label."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules are additive only.&lt;/strong&gt; There is no deny rule and no ordering. Policies from multiple objects union together, which is why the default-deny-plus-allows pattern works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policies are namespaced.&lt;/strong&gt; A policy in &lt;code&gt;shop&lt;/code&gt; cannot select pods in another namespace directly; cross-namespace rules go through &lt;code&gt;namespaceSelector&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: default-deny per namespace
&lt;/h2&gt;

&lt;p&gt;The baseline move in every namespace that runs real workloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&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;default-deny-all&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;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Egress&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply this and the namespace goes dark: nothing in, nothing out. You have not secured the app yet — you have made the allowed list explicit and empty. Now you earn each flow back deliberately. Do this in a staging namespace first, because the next thing that happens is always the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: allow DNS, the flow everyone forgets
&lt;/h2&gt;

&lt;p&gt;The first casualty of default-deny egress is DNS. Pods resolve names through the cluster DNS service (kube-dns/CoreDNS in &lt;code&gt;kube-system&lt;/code&gt;), and if egress to it is not allowed, every lookup fails and the app errors look like five unrelated bugs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&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;allow-dns-egress&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;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Egress&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&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;kubernetes.io/metadata.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
          &lt;span class="na"&gt;podSelector&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;k8s-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-dns&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UDP&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;53&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;53&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two subtleties: when a rule item contains both a &lt;code&gt;namespaceSelector&lt;/code&gt; and a &lt;code&gt;podSelector&lt;/code&gt;, they combine with AND — exactly what you want here. And allow TCP 53 as well as UDP 53; large responses and retries fall back to TCP, and "DNS mostly works" is a miserable state to debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: allow the real flows, one hop at a time
&lt;/h2&gt;

&lt;p&gt;Now walk your architecture diagram and convert each arrow into a rule. For a typical three-tier app in one namespace — web → api → postgres — the api-allow policy at the top of this guide is the middle hop. The web tier needs ingress from the ingress controller's namespace (via &lt;code&gt;namespaceSelector&lt;/code&gt;) and egress to the api. The database needs ingress only from the api and should usually have no egress at all beyond DNS.&lt;/p&gt;

&lt;p&gt;Work namespace by namespace, and keep the policies in Git next to the deployments they describe. If you run GitOps (the Argo CD guide in this series), a policy change then gets the same review trail as a code change — which is precisely the audit story you want for a security control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing policies: prove the negative
&lt;/h2&gt;

&lt;p&gt;The failure mode of Network Policies is silent — both kinds. Too open, and nothing complains while the risk stays. Too strict, and the app breaks in ways that look like application bugs. Test both directions deliberately:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Positive test:&lt;/strong&gt; the allowed flow works (web reaches api, api reaches postgres, names resolve).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negative test:&lt;/strong&gt; a forbidden flow fails. Exec into a pod — or spin up a throwaway debug pod in the namespace — and attempt the connection that should not work: the database port from the web tier, an external IP, another namespace's service. A timeout or refusal is the policy doing its job; a successful connect means the policy is not selecting what you think it is, or the CNI is not enforcing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A debug pod recipe that covers most cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run nettest &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;-n&lt;/span&gt; shop &lt;span class="nt"&gt;--&lt;/span&gt; bash
&lt;span class="c"&gt;# inside: dig api.shop.svc.cluster.local, nc -vz postgres 5432, curl to the api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;netshoot&lt;/code&gt; is a well-known troubleshooting image that bundles the usual network tools.) Label the debug pod like the tier you are testing — policies select by labels, so an unlabelled debug pod tests the default, not the app's reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Applying policies where the CNI ignores them.&lt;/strong&gt; Test enforcement first; a policy on Flannel-era setups is decoration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;policyTypes&lt;/code&gt;.&lt;/strong&gt; If you write egress rules but do not list &lt;code&gt;Egress&lt;/code&gt; in &lt;code&gt;policyTypes&lt;/code&gt;, your egress rules are not applied the way you expect — the API defaults &lt;code&gt;policyTypes&lt;/code&gt; based on what you wrote, and the surprises are all in the direction of "more open than intended" or "mysteriously closed." Be explicit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Denying DNS and chasing ghosts for an afternoon.&lt;/strong&gt; Any default-deny egress rollout needs the DNS allow in the same change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Selecting by namespace name strings.&lt;/strong&gt; &lt;code&gt;kubernetes.io/metadata.name&lt;/code&gt; is the label to match for a namespace; hand-rolled name labels drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trying to write deny rules.&lt;/strong&gt; There are none in the native API. Deny-by-exception designs belong to the CNI-specific policy types (Calico and Cilium both offer higher-level policy objects with ordering and explicit deny) — use them deliberately, not by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One giant policy per namespace.&lt;/strong&gt; Small, per-tier policies named after the flow (&lt;code&gt;api-allow&lt;/code&gt;, &lt;code&gt;postgres-ingress-from-api&lt;/code&gt;) stay readable and reviewable. A 300-line policy is where mistakes hide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring egress.&lt;/strong&gt; Ingress-only thinking leaves data exfiltration and command-and-control paths wide open. Locking egress to known destinations is the higher-value half of the work, and the half most teams skip.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this fits in the series
&lt;/h2&gt;

&lt;p&gt;Network Policies are one layer, not the whole story. They sit alongside the controls from the security-basics guide (RBAC, Pod Security Standards, secrets handling from the ConfigMaps and Secrets guide), the namespace and quota discipline from the resource requests and limits guide, and the upgrade hygiene that keeps the CNI itself current. No single layer carries the cluster; the layers are the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do Network Policies replace a service mesh?&lt;/strong&gt;&lt;br&gt;
No. Policies operate at L3/L4 — pods, namespaces, ports. A mesh adds identity-based, L7-aware controls (which service may call which path). Many clusters run policies alone happily; regulated or multi-tenant environments often run both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a policy allow traffic from outside the cluster?&lt;/strong&gt;&lt;br&gt;
Ingress rules accept &lt;code&gt;ipBlock&lt;/code&gt; entries (CIDR ranges), which is how you allow a load balancer range or an office VPN. Prefer selecting the ingress controller's pods by label when the traffic enters through it — CIDRs of cloud load balancers change more often than labels do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about traffic between nodes, or to the node itself?&lt;/strong&gt;&lt;br&gt;
Native policies govern pod traffic. Host and node-level rules are CNI-specific territory again (Calico's GlobalNetworkPolicy and Cilium's host policies) — reach for those when you need them, knowingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I roll this out on a live cluster without an outage?&lt;/strong&gt;&lt;br&gt;
Namespace by namespace, staging first: apply default-deny where a copy of the workload runs, add the flows, run the positive/negative tests, then repeat in production during a quiet window with the Git revert one command away. The rollout is a series of small, reversible changes — treat it with the same respect as the upgrades in this series' cluster-upgrades guide and it stays boring, which is the goal.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Amazon EKS in 2026: Managed Kubernetes on AWS, Explained for Working Engineers</title>
      <dc:creator>softverseAdmin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 09:15:41 +0000</pubDate>
      <link>https://dev.to/softverseadmin/amazon-eks-in-2026-managed-kubernetes-on-aws-explained-for-working-engineers-4749</link>
      <guid>https://dev.to/softverseadmin/amazon-eks-in-2026-managed-kubernetes-on-aws-explained-for-working-engineers-4749</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; EKS gives you a managed Kubernetes control plane and leaves everything else — nodes, networking shape, IAM wiring, add-ons — as your design decisions. The choices that matter: nodes come as managed node groups (the sensible default), self-managed groups (when you need control), or Fargate (serverless pods, with real constraints); Karpenter increasingly replaces static groups for teams that want nodes to follow demand. Networking runs on your VPC — pods get real VPC IPs from your subnets, which makes the &lt;a href="https://www.devopsenginer.com/blog/aws-vpc-guide-2026" rel="noopener noreferrer"&gt;VPC design&lt;/a&gt; a Kubernetes decision. IAM meets Kubernetes through Pod Identity (the current, simpler answer to "how does this pod get AWS permissions"), and the cluster is only production-shaped once the add-ons land: EBS CSI for storage, the Load Balancer Controller for ingress, external-dns and cert-manager for the public face. What EKS removes is the control plane burden. Everything in the rest of this series still applies — it just runs on AWS now.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devopsenginer.com/blog/amazon-eks-guide-2026" rel="noopener noreferrer"&gt;DevOps Enginer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most teams choosing EKS are not really choosing Kubernetes — they already know it. They are choosing to stop operating a control plane. What surprises them is how much remains theirs: EKS is managed Kubernetes, not managed &lt;em&gt;everything&lt;/em&gt;, and the gap between "cluster created" and "cluster production-ready" is a series of decisions this guide walks through.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AWS manages, and what it pointedly does not
&lt;/h2&gt;

&lt;p&gt;AWS runs the control plane — API servers and etcd across multiple AZs, patched and highly available — and charges a flat per-cluster hourly fee for it. That is the entire managed surface. &lt;strong&gt;Nodes are your EC2 instances&lt;/strong&gt; (or Fargate tasks), the VPC is yours, IAM is yours, and the add-ons that make a cluster useful are yours to install and maintain. Understanding this boundary prevents the most common EKS disappointment: expecting an app platform and receiving excellent infrastructure primitives.&lt;/p&gt;

&lt;p&gt;Cluster creation itself (eksctl, Terraform, console) is the easy afternoon. The decisions below are the ones with consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nodes: three ways to run pods, and a fourth that changes the question
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Managed node groups&lt;/strong&gt; — AWS handles the EC2 lifecycle (provisioning, AMI updates, draining on termination) behind a group definition: instance type, size range, subnets, labels. This is the default recommendation: boring, supported, and sufficient for most clusters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-managed node groups&lt;/strong&gt; — you own the Auto Scaling group and the bootstrap. Justified by custom AMIs, unusual instance configuration, or requirements the managed path doesn't expose. You also own every patch and every upgrade chore — the &lt;a href="https://www.devopsenginer.com/blog/kubernetes-upgrades-guide-2026" rel="noopener noreferrer"&gt;upgrades guide&lt;/a&gt; applies with extra steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fargate&lt;/strong&gt; — no nodes at all: each pod runs in an isolated micro-VM with a per-pod price. The constraints are the fine print: no DaemonSets, no persistent volumes beyond EFS, pod sizes from a fixed menu, and startup latency that batch and CI workloads forgive and latency-sensitive services don't. Excellent for spiky, stateless, low-count workloads; a poor default for a whole cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Karpenter&lt;/strong&gt; — the open-source autoscaler from AWS that increasingly replaces static groups and the cluster-autoscaler pattern: it watches pending pods and launches &lt;em&gt;right-sized&lt;/em&gt; instances (type, family, AZ chosen per workload) in under a minute, then consolidates under-used nodes away. For clusters whose demand moves — most clusters — Karpenter is where the &lt;a href="https://www.devopsenginer.com/blog/kubernetes-autoscaling-guide-2026" rel="noopener noreferrer"&gt;autoscaling story&lt;/a&gt; lands on AWS, and its cost effect is the rare one that shows up on the bill uninvited, in the good direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Networking: your VPC is the cluster network
&lt;/h2&gt;

&lt;p&gt;EKS uses the AWS VPC CNI by default, which means &lt;strong&gt;pods receive real IP addresses from your VPC subnets&lt;/strong&gt;. The consequences are pleasant (pods are first-class network citizens; security groups can even attach per-pod) and constraining (pod density per node is limited by ENI/IP capacity, and subnet IP space is a real planning input — small subnets plus big clusters is a known way to run out of addresses). Prefix delegation raises pod density substantially where needed. This is why the subnet sizing in the VPC guide leaned generous: Kubernetes spends IP addresses like it means it.&lt;/p&gt;

&lt;p&gt;The public face of the cluster is the &lt;strong&gt;AWS Load Balancer Controller&lt;/strong&gt;: annotate an Ingress or Service and it provisions an ALB/NLB with target groups wired to your pods — the AWS-native version of the &lt;a href="https://www.devopsenginer.com/blog/kubernetes-ingress-networking-basics-2026" rel="noopener noreferrer"&gt;ingress patterns&lt;/a&gt; covered earlier. Add &lt;strong&gt;external-dns&lt;/strong&gt; (Route 53 records follow your ingresses automatically) and &lt;strong&gt;cert-manager&lt;/strong&gt; with ACM or Let's Encrypt, and "deploy an app, get a public HTTPS URL" becomes the paved road rather than a manual ritual.&lt;/p&gt;

&lt;h2&gt;
  
  
  IAM inside the cluster: Pod Identity is the current answer
&lt;/h2&gt;

&lt;p&gt;Pods need AWS permissions — read this bucket, publish to that queue — and the cardinal rule is unchanged from everywhere else in AWS: &lt;strong&gt;per-workload roles, never node-wide credentials.&lt;/strong&gt; A node role big enough for every pod on the node is a privilege-escalation welcome mat, per the &lt;a href="https://www.devopsenginer.com/blog/aws-iam-least-privilege-guide-2026" rel="noopener noreferrer"&gt;IAM least-privilege guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;EKS has had two mechanisms. &lt;strong&gt;IRSA&lt;/strong&gt; (IAM Roles for Service Accounts) works via OIDC and annotations and remains everywhere in existing clusters. &lt;strong&gt;EKS Pod Identity&lt;/strong&gt; is the newer, simpler successor: associate an IAM role with a service account through the EKS API, an agent on the node handles credentials, and the annotation archaeology of IRSA goes away. New clusters should start on Pod Identity; existing ones can run both while migrating. Either way, the &lt;em&gt;design&lt;/em&gt; is identical: one role per workload, scoped to the resources that workload touches, with the cluster components (CSI driver, LB controller, Karpenter, external-dns) each holding their own deliberately-sized roles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The add-ons that make it production-shaped
&lt;/h2&gt;

&lt;p&gt;A fresh EKS cluster is deliberately minimal. The standard load-out, whether installed as EKS add-ons or via Helm/Argo CD:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;EBS CSI driver&lt;/strong&gt; — without it, PersistentVolumes on EBS don't exist; the &lt;a href="https://www.devopsenginer.com/blog/kubernetes-storage-guide-2026" rel="noopener noreferrer"&gt;storage model&lt;/a&gt; (gp3 classes, Retain policies, snapshots) hangs off this driver on AWS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Load Balancer Controller&lt;/strong&gt; — ingress and services, as above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CoreDNS and kube-proxy&lt;/strong&gt; — shipped as EKS add-ons; keep their versions in step with cluster upgrades rather than discovering skew during one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics and logs&lt;/strong&gt; — metrics-server or the Prometheus stack for the &lt;a href="https://www.devopsenginer.com/blog/kubernetes-observability-guide-2026" rel="noopener noreferrer"&gt;observability baseline&lt;/a&gt;, plus a log route (Fluent Bit to CloudWatch Logs or your aggregator). A cluster nobody can see into is a cluster on borrowed time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster access&lt;/strong&gt; — modern EKS uses &lt;em&gt;access entries&lt;/em&gt; (API-based authentication mapping IAM principals to Kubernetes permissions), replacing the hand-edited &lt;code&gt;aws-auth&lt;/code&gt; ConfigMap that caused a generation of "I locked everyone out of the cluster" incidents. Whoever provisions the cluster should set access policy deliberately on day one; the creator-admin trap (only the creating identity can administer, ever) is an old rite of passage best skipped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything after installation is the standard Kubernetes operating model — &lt;a href="https://www.devopsenginer.com/blog/argo-cd-tutorial-gitops-2026" rel="noopener noreferrer"&gt;GitOps delivery&lt;/a&gt;, &lt;a href="https://www.devopsenginer.com/blog/kubernetes-deployment-strategies-2025-complete-guide" rel="noopener noreferrer"&gt;progressive rollouts&lt;/a&gt;, &lt;a href="https://www.devopsenginer.com/blog/kubernetes-security-basics-guide-2026" rel="noopener noreferrer"&gt;security basics&lt;/a&gt;. EKS changes the substrate, not the discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrades and cost: the two recurring appointments
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Upgrades&lt;/strong&gt; follow the standard Kubernetes rhythm with AWS holding the control-plane end: you trigger the control-plane upgrade one minor version at a time, then roll node groups (or let Karpenter drift-replace nodes onto the new AMI), then bump add-ons. Support windows matter here: standard support for a Kubernetes version on EKS runs about 14 months from its EKS release, with paid extended support beyond — the calendar in the upgrades guide applies verbatim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost&lt;/strong&gt; has three components people chronically under-model: the per-cluster control-plane fee (multiplied by every forgotten dev cluster — see the &lt;a href="https://www.devopsenginer.com/blog/kubernetes-cost-optimization-guide-2026" rel="noopener noreferrer"&gt;Kubernetes cost guide&lt;/a&gt; for why cluster sprawl is the quiet line item), the nodes themselves (where Karpenter's right-sizing and consolidation do real work, plus Spot for fault-tolerant capacity), and the AWS perimeter — NAT gateways, load balancers and data transfer that the VPC design decided before the cluster existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expecting a platform.&lt;/strong&gt; EKS is infrastructure with a managed control plane. Teams that budget zero platform work discover the add-on, IAM and networking decisions arriving anyway, unscheduled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The node role that does everything.&lt;/strong&gt; One broad role for the node group means every pod inherits it. Per-workload identity via Pod Identity is the entire fix, and it's cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subnets sized for the demo.&lt;/strong&gt; Pods consume VPC IPs directly; small subnets cap cluster growth in a way that surfaces mid-incident. Size for the cluster you expect, with the VPC's spare range held back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;aws-auth archaeology.&lt;/strong&gt; Hand-editing the access ConfigMap (on clusters still using it) locks teams out with one typo. Prefer access entries; if you must edit aws-auth, do it from a session that stays open until a second login is verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add-ons drifting behind the cluster version.&lt;/strong&gt; CSI and LB controller versions have compatibility windows; treat add-on upgrades as part of the cluster upgrade runbook, not a separate someday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cluster per team per environment, forever.&lt;/strong&gt; The per-cluster fee plus duplicated add-ons and perimeter makes sprawl a budget line. Consolidate with namespaces and per-team IAM where isolation requirements genuinely allow it — and keep dedicated clusters where they don't (compliance, blast radius), knowingly.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;EKS vs self-managed Kubernetes on EC2?&lt;/strong&gt;&lt;br&gt;
Self-managed gives total control and total responsibility: control-plane HA, etcd backups, and patching are yours. EKS removes exactly that layer for a flat fee. Teams choose self-managed for specific control or cost-at-scale reasons; everyone else is buying back their weekends with EKS, correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EKS vs ECS Fargate?&lt;/strong&gt;&lt;br&gt;
If the team knows Kubernetes or needs its ecosystem (Helm charts, operators, the tooling in this series), EKS. If the need is "run these containers on AWS with minimal concepts," ECS is simpler and entirely respectable. The honest test is which ecosystem your hiring, tooling and existing workloads already speak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run a cluster with no managed node groups at all?&lt;/strong&gt;&lt;br&gt;
Yes — Fargate-only clusters exist and suit small, spiky, stateless estates. The DaemonSet and storage constraints decide for most teams; mixed setups (node groups for the baseline, Fargate for burst jobs) are common.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many clusters should we have?&lt;/strong&gt;&lt;br&gt;
Fewer than instinct suggests, more than one for anything serious: separate production from non-production at minimum. Beyond that, let blast radius, compliance boundaries and team autonomy argue it out — with the per-cluster and perimeter costs on the table while they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;EKS is a trade stated plainly: AWS operates the control plane; you keep the architecture. Choose node strategy deliberately (managed groups by default, Karpenter when demand moves), design the VPC like the cluster depends on it — because it literally runs on it — wire IAM per workload with Pod Identity, and install the add-ons that turn a cluster into a platform. Do those things and EKS delivers what it promises: Kubernetes without the control-plane pager. The rest of running Kubernetes well is, as ever, on you — and it's what the other fifteen parts of this series are for.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The free &lt;a href="https://www.devopsenginer.com" rel="noopener noreferrer"&gt;Kubernetes Production Checklist&lt;/a&gt; (via the newsletter on the homepage) includes the production-readiness items this guide builds toward — access, backups, observability and upgrade readiness.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>eks</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
