<?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: DevOps Oasis</title>
    <description>The latest articles on DEV Community by DevOps Oasis (@devops_oasis).</description>
    <link>https://dev.to/devops_oasis</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%2F1664697%2F71f5be49-a75b-4e87-a786-02c1073dde50.jpg</url>
      <title>DEV Community: DevOps Oasis</title>
      <link>https://dev.to/devops_oasis</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devops_oasis"/>
    <language>en</language>
    <item>
      <title>Docker Tags Make Production Rollbacks Ambiguous</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Thu, 27 Aug 2026 17:31:28 +0000</pubDate>
      <link>https://dev.to/devops_oasis/docker-tags-make-production-rollbacks-ambiguous-246g</link>
      <guid>https://dev.to/devops_oasis/docker-tags-make-production-rollbacks-ambiguous-246g</guid>
      <description>&lt;p&gt;&lt;em&gt;Our production manifests should name bytes, not somebody’s label.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;While the registry UI was showing &lt;code&gt;ledger-api:2026.08.3&lt;/code&gt; with two different upload times, I decided we should deploy Docker images by digest, even when a version tag looks perfectly respectable. A tag is a label somebody can move; production should name the exact bytes it intends to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Docker Tag Is A Promise We Cannot Verify Later
&lt;/h2&gt;

&lt;p&gt;We still create tags. Developers need them, release notes need them, and nobody wants to read a digest aloud during an incident unless morale has already collapsed. &lt;code&gt;2026.08.3&lt;/code&gt; tells a human roughly what they are looking at. &lt;code&gt;sha256:5bb4…&lt;/code&gt; tells a machine precisely what it is looking at.&lt;/p&gt;

&lt;p&gt;Those are different jobs, and treating one identifier as both has caused more confusion than it deserves.&lt;/p&gt;

&lt;p&gt;A Docker image digest identifies a content-addressed manifest. Docker documents the distinction clearly in its &lt;a href="https://docs.docker.com/reference/cli/docker/image/pull/#pull-an-image-by-digest-immutable-identifier" rel="noopener noreferrer"&gt;image digests reference&lt;/a&gt;: tags can change, while a digest points to a specific image manifest. The &lt;a href="https://github.com/opencontainers/image-spec/blob/main/descriptor.md" rel="noopener noreferrer"&gt;OCI image specification&lt;/a&gt; is equally blunt about descriptors being identified by digest. We knew this, of course. Knowing a thing and arranging work around it are separate departments.&lt;/p&gt;

&lt;p&gt;My opinion is that a production deployment manifest should contain &lt;code&gt;registry.example.net/ledger-api@sha256:...&lt;/code&gt;, not &lt;code&gt;registry.example.net/ledger-api:2026.08.3&lt;/code&gt;. The tag belongs beside it in Git, in the release ticket, and in the message we send when a deploy starts. It does not belong where the scheduler decides what to pull.&lt;/p&gt;

&lt;p&gt;The common reply is that version tags are immutable in a well-run registry. That is usually shorthand for “we asked people not to overwrite them.” A permission policy is better. A registry setting that rejects overwrites is better still. Neither changes the fact that the deployment record itself is vague when it names a tag.&lt;/p&gt;

&lt;p&gt;If we need to answer “what was running at 02:13?”, we should not need to reconstruct registry history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Immutable Tags Are Good Advice With A Missing Clause
&lt;/h2&gt;

&lt;p&gt;The usual advice is to use semantic version tags and make them immutable. We did that for a while. Our GitHub Actions workflow built &lt;code&gt;ledger-api:2026.08.3&lt;/code&gt;, pushed it to Harbor, and our deployment repository updated the same tag in the Helm values file.&lt;/p&gt;

&lt;p&gt;That worked right up to the point it did not.&lt;/p&gt;

&lt;p&gt;In February, a build for the ARM64 variant finished after the AMD64 build. The release job had pushed a single-platform manifest under the version tag first, then replaced it with a multi-platform manifest list once the second build arrived. Nobody was trying to smuggle a patched image into production. The workflow was merely assembled in the order that seemed convenient on a Wednesday morning.&lt;/p&gt;

&lt;p&gt;The digest changed anyway.&lt;/p&gt;

&lt;p&gt;Our production nodes are currently AMD64, so the pods kept working. A staging runner on ARM64 started pulling the newer manifest list. Both environments reported &lt;code&gt;2026.02.6&lt;/code&gt;; they were not consuming the same top-level image object. The difference mattered when an engineer tried to reproduce a staging-only startup failure using the production digest from the cluster event log.&lt;/p&gt;

&lt;p&gt;We fixed the build ordering. Then we noticed the larger flaw: even an immutable tag provides a weak deployment record if the tag was resolved at different times by different tools.&lt;/p&gt;

&lt;p&gt;Kubernetes has sensible rules here, but they are not a substitute for stating intent. Its &lt;a href="https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy" rel="noopener noreferrer"&gt;image pull policy documentation&lt;/a&gt; explains that &lt;code&gt;Always&lt;/code&gt; checks the registry on each launch, while the runtime can reuse locally cached layers and manifests as appropriate. If the image reference includes a tag, the kubelet resolves what that tag means when it pulls. If it includes a digest, there is little to interpret.&lt;/p&gt;

&lt;p&gt;“Immutable tags” solve accidental replacement. They do not make a tag an exact deployment reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Release Controller Stopped Guessing In April
&lt;/h2&gt;

&lt;p&gt;We changed the release path for six customer-facing services in April: &lt;code&gt;ledger-api&lt;/code&gt;, &lt;code&gt;settlement-worker&lt;/code&gt;, &lt;code&gt;webhooks&lt;/code&gt;, &lt;code&gt;invoice-renderer&lt;/code&gt;, &lt;code&gt;auth-edge&lt;/code&gt;, and &lt;code&gt;report-export&lt;/code&gt;. GitHub Actions still pushes a readable tag. After the registry has accepted the final multi-architecture manifest, the workflow asks Harbor for its digest and writes that digest into the deployment pull request.&lt;/p&gt;

&lt;p&gt;The pull request title still says &lt;code&gt;ledger-api 2026.08.3&lt;/code&gt;. The changed line says something less charming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;harbor.ops.internal/ledger-api@sha256:5bb4c0e31c4d9f0a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line has made incident handling noticeably less theatrical.&lt;/p&gt;

&lt;p&gt;On 7 July, &lt;code&gt;invoice-renderer&lt;/code&gt; began returning 500 responses for PDFs containing a non-breaking space in an address field. The release had gone out at 10:06. At 10:19, the on-call engineer found the exact digest in the Argo CD application history, opened the prior deployment commit, and reverted one Git change. The replacement pods were ready by 10:27.&lt;/p&gt;

&lt;p&gt;Previously, our rollback notes said things like “back to 1.18.4.” This looks clear until someone asks whether 1.18.4 means the original image, the image rebuilt after a base-image CVE, or the one an engineer pushed locally during a failed release rehearsal. We had all three at one point. No amount of confident punctuation fixes that.&lt;/p&gt;

&lt;p&gt;The registry tag now has a narrower purpose. It is a discovery handle: find the release, find the digest, inspect the provenance. We attach the Git commit, build run URL, SBOM location, and source tag as OCI annotations. The &lt;a href="https://github.com/opencontainers/image-spec/blob/main/annotations.md" rel="noopener noreferrer"&gt;OCI annotations guidance&lt;/a&gt; gives us standard fields for much of this, though we also carry two internal labels because apparently every organisation eventually invents metadata nobody else wants.&lt;/p&gt;

&lt;p&gt;This only held because our deployment repository already owned the image reference. Teams using a CI system that applies directly to a cluster have a harder migration. There is no pull request in which to review the digest, and a shell history is a poor change record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Best Objection Is That Digests Hurt Humans
&lt;/h2&gt;

&lt;p&gt;The strongest argument against digest-pinned Docker deployments is that they make ordinary work worse.&lt;/p&gt;

&lt;p&gt;That argument has teeth. A digest cannot tell us whether it contains version 3.12.1 or 3.12.2 of an application. It cannot tell us which branch produced it. If a developer sees a 71-character hash in a Kubernetes event, they need another system to map it back to the release. A version tag is readable in a way hashes never will be, and pretending otherwise is how we end up making people keep private spreadsheets.&lt;/p&gt;

&lt;p&gt;Digests also complicate emergency changes. If an engineer needs to deploy a temporary image at 01:40, asking them to look up a manifest digest, edit a Git file, wait for reconciliation, and check policy output can feel like process taking a victory lap around a burning bin.&lt;/p&gt;

&lt;p&gt;We have done the 01:40 version of this. The Docker registry was healthy, the cluster was healthy, and the thing slowing us down was our own release machinery rejecting an image because the SBOM upload had lagged by four minutes. The service had a memory leak, not a compliance emergency. Still, nobody on the call found the delay spiritually enriching.&lt;/p&gt;

&lt;p&gt;There is also a real security concern in the opposite direction. A digest pins an image, including a vulnerable one. A floating tag can pick up a patched base image when a pod restarts. That behaviour is uncontrolled, but it may reduce exposure in a narrow case. Pinning forces us to deliberately rebuild and redeploy, which is operationally correct and sometimes operationally inconvenient.&lt;/p&gt;

&lt;p&gt;I still prefer the inconvenience. Silent changes in production are worse than explicit changes arriving later than we would like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Digest Pinning Fails For Some Kinds Of Docker Work
&lt;/h2&gt;

&lt;p&gt;We do not pin every Docker reference by digest.&lt;/p&gt;

&lt;p&gt;Local development is the obvious exception. Our compose file uses &lt;code&gt;postgres:16.4&lt;/code&gt;, &lt;code&gt;redis:7.4&lt;/code&gt;, and a few internally tagged service images because the point is to get a working stack onto a laptop before lunch. Forcing every developer to update a digest for every base-image refresh would create a small administrative cult and very little safety.&lt;/p&gt;

&lt;p&gt;Ephemeral preview environments get tags too. A pull request deployment named &lt;code&gt;pr-1847&lt;/code&gt; is intentionally disposable, and its tag tells us more than a hash does when we are cleaning up fifty previews after a busy week. We retain the build metadata, but we do not pretend those environments have the same change-control needs as production.&lt;/p&gt;

&lt;p&gt;Base images are messier still. We pin our production Dockerfiles to digests for &lt;code&gt;cgr.dev/chainguard/node&lt;/code&gt;, but that means Renovate opens digest update pull requests several times a week. Priya, who owns most of our runtime maintenance, thinks this is a fair price for seeing every base-image movement. I think the PR volume is irritating, although I have not volunteered to triage it, so my objection has limited moral force.&lt;/p&gt;

&lt;p&gt;We have not tried digest-based deployment past 40 nodes or across separate regional registries. Our largest cluster has 28 worker nodes, and Harbor sits on the same internal network as it. If a team relies on registry mirrors with uneven replication, a digest may expose propagation mistakes that a tag previously hid. Hiding the mistake was not a solution, obviously, but an unavailable deployment is still unavailable.&lt;/p&gt;

&lt;p&gt;There is one unresolved problem in our setup: old digest retention. We want enough history to roll back an image from six months ago, but storing every release, failed build, and preview manifest has pushed Harbor’s object storage bill up 31% since January. We are arguing over whether retention should follow deployment history or vulnerability status. Those rules produce very different bins of images.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep Tags For People And Digests For Schedulers
&lt;/h2&gt;

&lt;p&gt;The practical version of this policy is less dramatic than it sounds.&lt;/p&gt;

&lt;p&gt;Build an image once. Tag it with a release version, a Git SHA, and whatever temporary CI name helps the workflow. Publish the final manifest only after every required architecture is present. Read the digest from the registry after that publication, rather than calculating it from a local build result. Then place the digest in the production deployment definition.&lt;/p&gt;

&lt;p&gt;Keep the readable information close by. Our release pull requests include the version tag, commit SHA, previous digest, new digest, and a link to the registry artifact. Argo CD shows the digest in the application history. Our &lt;code&gt;#release-watch&lt;/code&gt; message includes both references, because people searching Slack will type &lt;code&gt;2026.08.3&lt;/code&gt;, not 64 hexadecimal characters.&lt;/p&gt;

&lt;p&gt;We also block tag-only production images through admission policy. We chose Kyverno for this because the policy reads like something a tired engineer can inspect during an incident. The initial rollout was report-only for two weeks. It found three CronJobs and a forgotten namespace called &lt;code&gt;data-scratch&lt;/code&gt;, which had survived two reorganisations and a platform migration through what I can only call administrative fog.&lt;/p&gt;

&lt;p&gt;The policy has an escape hatch for an incident commander, valid for two hours and logged to our audit channel. We have used it once. The follow-up action was not “remove the guardrail”; it was “make the release workflow stop waiting for an irrelevant test suite.”&lt;/p&gt;

&lt;p&gt;I would rather explain a long digest during a rollback than explain why the same Docker tag meant different images on different days. We still need to settle retention before the storage bill becomes its own ticket queue.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Terraform State Locking During Tuesday’s IAM Cleanup</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Thu, 27 Aug 2026 17:25:21 +0000</pubDate>
      <link>https://dev.to/devops_oasis/terraform-state-locking-during-tuesdays-iam-cleanup-90k</link>
      <guid>https://dev.to/devops_oasis/terraform-state-locking-during-tuesdays-iam-cleanup-90k</guid>
      <description>&lt;p&gt;&lt;em&gt;One stale lock, one real permission gap, and a policy we still dislike.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;09:12.&lt;/p&gt;

&lt;p&gt;That was when stand-up stopped being stand-up and turned into three people staring at a Terraform lock held by a GitLab runner that had supposedly finished forty minutes earlier. Someone had brought a box of almond croissants, which made the delay feel less hostile than it deserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  09:12 — Stand-Up Opens On A Locked State
&lt;/h2&gt;

&lt;p&gt;The ticket was small enough to sound harmless: remove the remaining broad access from the &lt;code&gt;data-export&lt;/code&gt; role in our production analytics account. Finance had moved the nightly export job to a narrower S3 prefix two weeks earlier, and the old policy still allowed reads across the whole reports bucket.&lt;/p&gt;

&lt;p&gt;We’d already approved the pull request on Monday. The plan had shown one IAM policy replacement and no resource destruction. By our standards, it was almost suspiciously tidy.&lt;/p&gt;

&lt;p&gt;Then the scheduled apply job failed while acquiring the state lock. The Terraform state for this account still lives in an S3 backend with DynamoDB locking. We know newer backend options exist. We also know that “we should migrate state locking later” has been on our board since November, sitting beside “replace the office chair that squeaks during incident calls.”&lt;/p&gt;

&lt;p&gt;A failed lock is not automatically a stale lock. That distinction matters more than people think. An engineer running a local apply from a train Wi-Fi connection is annoying. Two applies changing IAM policies against the same state are a much better way to spend Tuesday explaining ourselves to auditors.&lt;/p&gt;

&lt;p&gt;We checked the GitLab job page first. It had not failed cleanly. The runner had lost contact during the apply stage, then sat in a “running” state long enough for GitLab to declare it dead. The EC2 runner instance had been replaced overnight by its autoscaling group. The lock remained.&lt;/p&gt;

&lt;p&gt;Nobody touched &lt;code&gt;force-unlock&lt;/code&gt; during stand-up. We’ve earned that restraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  09:40 — We Find The State File Everybody Shares
&lt;/h2&gt;

&lt;p&gt;By 09:40, we had the lock ID, the runner hostname, and the commit SHA from the previous night. The useful question was whether Terraform had made any changes before the runner disappeared.&lt;/p&gt;

&lt;p&gt;Our analytics account state is larger than we’d like: 186 managed objects, including IAM roles, S3 bucket policies, EventBridge schedules, a few Glue jobs, and the KMS grants that always become someone’s problem eventually. It grew this way because “analytics” was a convenient boundary when there were two data jobs and one AWS account. We now have five account environments and a state file that makes reviewers scroll for longer than is healthy.&lt;/p&gt;

&lt;p&gt;We checked CloudTrail for IAM policy changes around the runner’s last heartbeat. Nothing. We checked the S3 backend version history. No new state object version. The runner had acquired the lock, downloaded state, and disappeared before it changed AWS or wrote anything back.&lt;/p&gt;

&lt;p&gt;That gave us enough confidence to remove the lock manually. Terraform’s own &lt;a href="https://developer.hashicorp.com/terraform/language/state/locking" rel="noopener noreferrer"&gt;state-locking documentation&lt;/a&gt; is sensible on this point: locks prevent concurrent writes; they do not tell us whether a missing process was harmless. We still need evidence from the provider side.&lt;/p&gt;

&lt;p&gt;We keep a short runbook for this. Not a twelve-page document with colour-coded arrows. Just the account, backend location, lock table, who can clear a lock, and the checks required before doing it. The last line says: “A green GitLab page is not evidence.” That line arrived after a green GitLab page turned out to be reporting an old runner status from a job that had already vanished.&lt;/p&gt;

&lt;h2&gt;
  
  
  10:25 — The Plan Finds A Permission We Had Missed
&lt;/h2&gt;

&lt;p&gt;With the lock cleared, we ran the approved plan again from a fresh runner. The policy change itself was plain: remove a bucket-wide &lt;code&gt;s3:GetObject&lt;/code&gt; permission and retain access to &lt;code&gt;reports/finance-export/*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second plan differed from Monday’s plan.&lt;/p&gt;

&lt;p&gt;Terraform wanted to add &lt;code&gt;s3:ListBucket&lt;/code&gt; with a prefix condition. Monday’s version had not. We hadn’t changed the policy file. The difference came from a module release merged by another team earlier that morning. Their update corrected how the module built conditions for prefix-scoped S3 access. Our configuration picked it up because we had allowed a minor module version range.&lt;/p&gt;

&lt;p&gt;This is why we pin Terraform providers and have a &lt;code&gt;terraform.lock.hcl&lt;/code&gt; file, but we are less relaxed about internal module ranges than we used to be. Provider dependency locks are explained well in HashiCorp’s &lt;a href="https://developer.hashicorp.com/terraform/language/files/dependency-lock" rel="noopener noreferrer"&gt;dependency lock file documentation&lt;/a&gt;. Modules need the same degree of suspicion, even when they live in our own GitLab group and have a reassuring name like &lt;code&gt;aws-iam-basics&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The added permission was correct. An S3 client that reads objects often needs to list the prefix first, depending on how it discovers keys. Removing bucket-wide reads while leaving the job unable to list its approved prefix would have made the nightly export fail at 02:00, which is an unpleasant way to prove least privilege.&lt;/p&gt;

&lt;p&gt;We paused the apply and asked the data engineering team to confirm the export’s call pattern. They replied with a CloudTrail event from last week: &lt;code&gt;ListBucket&lt;/code&gt;, then &lt;code&gt;GetObject&lt;/code&gt; against a timestamped CSV under the expected prefix. Good. Concrete evidence beats everybody’s memory of what a Python script “probably does.”&lt;/p&gt;

&lt;p&gt;AWS’s &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_evaluation-logic.html" rel="noopener noreferrer"&gt;policy evaluation logic&lt;/a&gt; is worth reading whenever an IAM change feels obvious. Explicit denies, identity policies, resource policies, permission boundaries, session policies: it is entirely possible to be right about the line you removed and wrong about the permission path that remains.&lt;/p&gt;

&lt;h2&gt;
  
  
  11:50 — We Leave The Provider Upgrade Alone
&lt;/h2&gt;

&lt;p&gt;Before lunch, somebody noticed the runner image carried Terraform 1.9.8 while our newest platform image had 1.10.5. The AWS provider was at 5.72.1 in this repository, though several newer versions were available.&lt;/p&gt;

&lt;p&gt;This produced the usual suggestion: while we are in here, should we update everything?&lt;/p&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;We dislike opportunistic Terraform upgrades during access changes. A provider upgrade can alter a plan in ways that have nothing to do with the ticket in front of us. Sometimes that is exactly the point of upgrading. It deserves its own pull request, its own review, and preferably an afternoon where the person doing it does not also have pager duty.&lt;/p&gt;

&lt;p&gt;Terraform plans are useful, but they are not contracts with AWS. They are predictions from a specific Terraform version, provider version, state snapshot, and set of credentials. Change one part of that set and we need to read the prediction again. The &lt;a href="https://developer.hashicorp.com/terraform/cli/commands/plan" rel="noopener noreferrer"&gt;plan command reference&lt;/a&gt; says plenty about modes and options; the operational bit is simpler: save the plan we reviewed, then apply that plan from an environment we control.&lt;/p&gt;

&lt;p&gt;This only held because our applies come from a small pool of disposable GitLab runners, all using the same container image and IAM role. We have not tried this setup across forty separate teams with their own runner images, and we do not pretend the habits would survive unchanged.&lt;/p&gt;

&lt;p&gt;Lunch was reheated noodles eaten while someone explained why their new mechanical keyboard had “thock.” We agreed it made a noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  13:20 — The Apply Produces One Useful Scare
&lt;/h2&gt;

&lt;p&gt;Data engineering confirmed the prefix requirement just after one. We accepted the module change, regenerated the plan, and had the reviewer look at the rendered IAM document rather than trusting the resource count.&lt;/p&gt;

&lt;p&gt;The apply completed in 38 seconds. We kept the terminal output in the ticket because the first failure and the successful retry told a clearer story together than a green pipeline badge did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform apply tfplan-2026-08-27-1314

Acquiring state lock. This may take a few moments...
aws_iam_role_policy.data_export: Modifying... [id=data-export:read-finance-reports]
aws_iam_role_policy.data_export: Modifications complete after 3s [id=data-export:read-finance-reports]

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

Outputs:

data_export_role_arn = "arn:aws:iam::482761903155:role/data-export"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful scare came five minutes later. The export job’s pre-flight check failed in staging because its assumed-role session policy still allowed the old path but did not include the new list action. Terraform had done precisely what we asked. Our staging job wrapper had quietly supplied an extra session policy, which is the sort of detail nobody remembers until it blocks an otherwise correct change.&lt;/p&gt;

&lt;p&gt;We fixed that in the job repository, not in Terraform. Giving the base IAM role broader access to compensate would have been lazy. Session policies are where we want this restriction, because the export job is the only caller that needs it.&lt;/p&gt;

&lt;p&gt;By 13:48, the staging run had listed the prefix, read its test object, and exited. Production followed after the normal approval window.&lt;/p&gt;

&lt;h2&gt;
  
  
  14:35 — Review Turns Into A State Boundary Argument
&lt;/h2&gt;

&lt;p&gt;The reviewer’s main comment was not about IAM. It was about why a data export policy shares state with Glue jobs, KMS grants, and EventBridge rules that have nothing to do with one another.&lt;/p&gt;

&lt;p&gt;Fair.&lt;/p&gt;

&lt;p&gt;We have been arguing for months about whether to split the analytics state by service ownership or by change frequency. Our preference is change frequency. IAM and bucket policies change in small, review-heavy increments; Glue jobs tend to move with application releases; KMS grants barely change until they suddenly ruin someone’s week. Putting them all in one state means an IAM cleanup takes a lock on work unrelated to IAM.&lt;/p&gt;

&lt;p&gt;The opposing view is that splitting state produces more remote-state references, more permissions, and more ways to apply components in the wrong order. Also fair. Terraform dependency graphs are pleasant inside one state file and much less charming once we start wiring outputs across repositories.&lt;/p&gt;

&lt;p&gt;We still think the current file is too broad. We do not think “one state per microservice” is an answer. That way lies 70 tiny repositories, each with a README last touched in 2024 and an owner who moved teams.&lt;/p&gt;

&lt;p&gt;The unresolved part is KMS. Its key policies and grants cut across enough services that no proposed boundary looks clean. We can move the export role and bucket policy into their own state. We have not agreed where the encryption key belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  16:10 — Pager Duty Gets Dragged Into Terraform Again
&lt;/h2&gt;

&lt;p&gt;At 16:10, our on-call engineer asked why they had needed to join a state-lock investigation for a change they did not own. It was a fair complaint, especially since they were already dealing with an alert from a flaky DNS health check.&lt;/p&gt;

&lt;p&gt;Our current rule says the person on pager can approve emergency infrastructure changes, but should not be the only person clearing a production state lock. The reason is boring: lock removal has enough blast radius that tired judgment is a poor control. The irritating part is that our access model makes the on-call role one of only four roles allowed to do it.&lt;/p&gt;

&lt;p&gt;We need to separate emergency visibility from emergency mutation. Right now, the same permission set grants read access to backend state metadata and the ability to remove a DynamoDB lock. That was convenient when the team was smaller. It now means an on-call engineer gets pulled into a routine recovery step merely because the rest of us designed the permissions around convenience.&lt;/p&gt;

&lt;p&gt;We added a ticket to create a narrow break-glass role with audit logging and a short session duration. It will probably sit next to the state-splitting work for a while. Terraform backlog items reproduce when left alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  17:05 — We Should Automate The Evidence, Not The Unlock
&lt;/h2&gt;

&lt;p&gt;The part of this day worth automating is the evidence gathering after a failed apply: runner status, last state version, relevant CloudTrail events, and whether any resource changed after the lock was created. A bot can collect those facts into the incident ticket in under a minute.&lt;/p&gt;

&lt;p&gt;We should not automate &lt;code&gt;force-unlock&lt;/code&gt; just because the bot gets impatient.&lt;/p&gt;

&lt;p&gt;The coffee was cold by then.&lt;/p&gt;

&lt;p&gt;We can make stale-lock recovery less manual, but a human should still decide whether an absent process is truly absent. The lock is there to make us stop before two people write different stories into the same state file, and we have enough of those already.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Scrum Myths We Retired After Thursday Spillover</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Sun, 23 Aug 2026 13:33:29 +0000</pubDate>
      <link>https://dev.to/devops_oasis/scrum-myths-we-retired-after-thursday-spillover-5095</link>
      <guid>https://dev.to/devops_oasis/scrum-myths-we-retired-after-thursday-spillover-5095</guid>
      <description>&lt;p&gt;&lt;em&gt;What our platform team learned when sprint planning met production work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;By 15:17 on Thursday, six of the nine cards we had promised for the sprint were still open, and the one everyone wanted finished was waiting on a DNS change from another team. Our board looked less like a plan and more like the contents of a drawer after someone has searched for a battery.&lt;/p&gt;

&lt;p&gt;We’d been running Scrum with the infrastructure group for four months: eight engineers, one engineering manager, a rotating facilitator, and enough Kubernetes work to make estimates feel mildly dishonest. We kept the ceremonies small, used two-week sprints, and made every operational interruption visible instead of pretending PagerDuty events arrived from another department.&lt;/p&gt;

&lt;p&gt;That last part mattered. So did admitting that some things people say about Scrum are true only in the same sense that “the lift is working” is true while it is stuck between floors.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Scrum Makes Delivery Predictable”
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Verdict: mostly false, unless the work is already fairly predictable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our first three sprints did make delivery look more predictable. We averaged 31 points, then 34, then 32. Somebody put the numbers in a slide. Somebody else drew a trend line. We all briefly enjoyed the illusion that we had discovered arithmetic.&lt;/p&gt;

&lt;p&gt;Sprint four contained a certificate rotation, a flaky EKS node group, two urgent access requests from finance, and a production issue where &lt;code&gt;orders-worker&lt;/code&gt; kept opening connections after its PostgreSQL failover target had changed. We completed 14 points. The number was not the problem; the work was. Half of it had arrived after planning, and the other half had hidden dependencies behind apparently sensible tickets.&lt;/p&gt;

&lt;p&gt;Scrum can force us to state an expectation before work begins. That is useful. It does not turn unknown work into known work, and it certainly does not persuade a cloud provider to schedule maintenance around our sprint boundary.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://scrumguides.org/scrum-guide.html" rel="noopener noreferrer"&gt;Scrum Guide&lt;/a&gt; is careful here: a Sprint Goal is an objective, not a contract for a fixed pile of tickets. Teams tend to forget this because tickets are easier to count than objectives are to discuss. “Improve the reliability of cluster upgrades” sounds vague in a planning meeting. “Complete PLAT-184 through PLAT-191” feels reassuring, right up until PLAT-187 reveals a three-year-old Terraform module nobody has touched since 2023.&lt;/p&gt;

&lt;p&gt;We changed our planning habit after that Thursday. We now select one Sprint Goal, reserve capacity for known operational work, and mark confidence on items with outside dependencies. A card waiting on Security, Data Engineering, or a vendor is not “in progress” in the same way as a card with a developer actively changing a file. This sounds obvious because it is obvious. We had still failed to do it.&lt;/p&gt;

&lt;p&gt;Our predictability got worse before it got better. In May, the percentage of planned work completed fell from 82% to 54% when we stopped quietly pulling unfinished cards into the next sprint and started recording interruption work properly. Finance did not love the first report. Neither did we. It also showed that on-call work was consuming about 11 engineer-days a month, which was difficult to ignore once the number had a row in it.&lt;/p&gt;

&lt;p&gt;Four sprints later, our completion rate settled around 73%, but the forecast conversations became less fictional. We can usually say whether a change will land in the next two weeks. We do not promise it when an external team holds the last key.&lt;/p&gt;

&lt;p&gt;That is about as predictable as infrastructure work gets, and we are happier with it than the old 90% figure built from invisible spillover.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Every Sprint Needs A Finished Commitment”
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Verdict: false, and this wording causes more trouble than it saves.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We tried treating the sprint backlog as a commitment in the contractual sense. At planning, every card was assigned, estimates were discussed until people began using phrases like “relative complexity” with straight faces, and the team left with a tidy board.&lt;/p&gt;

&lt;p&gt;Then Friday came.&lt;/p&gt;

&lt;p&gt;A committed backlog produces bad behaviour when reality moves. Engineers split cards late so something can reach Done. They avoid useful investigation because it threatens the count. Work that should be stopped carries on because abandoning it looks worse than spending another day on it. We saw all three during a migration from an old NGINX ingress setup to AWS Load Balancer Controller 2.7.&lt;/p&gt;

&lt;p&gt;One ticket was to move the customer-admin service. It had a seven-point estimate and a sensible acceptance list. During testing, we found that its websocket timeout had been set in an annotation inherited from a chart no one remembered choosing. The right response was to pause, reproduce the failure under load, and decide whether to move the service at all.&lt;/p&gt;

&lt;p&gt;Instead, we kept pushing. The ticket crossed the board on the last afternoon. It was “done” because the service answered HTTP requests in staging. The websocket issue returned in production at 09:12 on the following Monday. We had manufactured a green card and bought ourselves a support incident.&lt;/p&gt;

&lt;p&gt;A Sprint Goal can be a commitment. “Reduce the risk of expiring internal certificates before the September renewal window” gave us room to drop an unhelpful automation task and spend two days fixing the actual renewal path. The goal remained valid even when a ticket did not.&lt;/p&gt;

&lt;p&gt;We now call individual backlog items forecasts. Some people dislike the word because they think it softens accountability. We think it describes reality. An engineer still owns the work. The team still explains why it moved or did not move. Nobody gets to call a ticket “almost done” for three sprints and hide behind uncertainty.&lt;/p&gt;

&lt;p&gt;The distinction also makes review less ceremonial. We look at what changed for users or operators, what we learned, and what is parked. If a ticket has been open for more than ten working days, we ask whether it is too large, blocked, or simply no longer worth doing. That question has killed several pieces of work that would otherwise have become permanent residents of the board.&lt;/p&gt;

&lt;p&gt;We did try a “commitment buffer” of 20%, where every sprint had deliberately unallocated capacity. It did not work. Urgent work filled it by lunchtime on day two, and people started treating the remaining 80% as a harder promise. We removed it after two sprints. No grand lesson emerged. It was just an awkward number sitting on a board while the pager went off.&lt;/p&gt;

&lt;h2&gt;
  
  
  “The Daily Scrum Is A Status Meeting”
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Verdict: false, although many teams have made it one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our daily Scrum used to happen at 09:30, last 17 minutes, and contain a sequence of engineers reporting to a manager who already had access to the board. We called it a stand-up despite everyone being on video. It was not offensive; it was merely pointless.&lt;/p&gt;

&lt;p&gt;The manager stopped attending after we agreed that the meeting belonged to the people doing the sprint work. This was not a rebellion against management. There are enough of those already, usually involving spreadsheets. It was a decision to stop turning coordination into a recital.&lt;/p&gt;

&lt;p&gt;The format changed with one rule: talk only about the next 24 hours of progress toward the Sprint Goal, including anything likely to prevent it. “Yesterday I updated the Helm chart” is status. “The chart deploys, but the admission controller rejects our security context and I need Mina for 20 minutes after this” is coordination.&lt;/p&gt;

&lt;p&gt;That took some practice. The first week was full of habit. People gave the old three questions, then added a blocker as an afterthought. By week three, the useful discussions had become shorter and more direct. One engineer would say a task had expanded; another would point out a similar change in &lt;code&gt;payments-api&lt;/code&gt;; someone would decide to pair after the meeting rather than spend two days rediscovering the same failure.&lt;/p&gt;

&lt;p&gt;Our daily Scrum now runs for nine minutes on most days. On Mondays it can run longer because somebody has usually found a surprise over the weekend. If a production incident is active, we cancel it and use the incident channel instead. There is no prize for maintaining a ceremony during a fire.&lt;/p&gt;

&lt;p&gt;We also stopped forcing every person to speak. Silence is allowed if someone has no change to coordinate. This makes a few Scrum purists twitch, but the guide’s purpose for the event is inspection and adaptation, not attendance theatre.&lt;/p&gt;

&lt;p&gt;There is one unresolved argument here. We rotate facilitation every sprint, and two engineers still think the daily meeting becomes noticeably worse when the rotation reaches people who dislike meetings. They may be right. We have not yet decided whether a team-owned practice needs a consistent owner to stay useful, or whether that is just us avoiding a mildly uncomfortable skill gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Velocity Tells Us When To Hire”
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Verdict: false, and we dislike velocity as a management number.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Velocity tells us how many points a particular team finished under a particular estimating habit. It does not tell us how much work exists, whether the work is valuable, whether another engineer would remove a bottleneck, or whether the points themselves are anything more than educated hand gestures.&lt;/p&gt;

&lt;p&gt;We learned this when our eight-person platform group’s velocity rose from 29 to 41 across five sprints. The headcount had not changed. The work had. We had spent a month closing small access-control tasks and documentation gaps after a large migration. The average card got smaller. Our estimate scale drifted because people had become familiar with the code. No new capacity had appeared, unless confidence in &lt;code&gt;kubectl&lt;/code&gt; counts as capacity.&lt;/p&gt;

&lt;p&gt;A senior manager asked whether the data showed we could defer an open platform role. We said no, then had to explain why a rising line did not mean the team was suddenly 41% more capable. That conversation took longer than it should have, partly because we had supplied the line in the first place.&lt;/p&gt;

&lt;p&gt;For staffing, we now bring actual constraints: time spent on on-call, the number of services per primary responder, lead time for changes requiring platform review, and the queue of work we have agreed is worth doing. The &lt;a href="https://dora.dev/research/" rel="noopener noreferrer"&gt;DORA research&lt;/a&gt; is more useful than sprint velocity when discussing delivery performance, though it will not tell us whether we need someone who understands IAM policy evaluation at 02:00.&lt;/p&gt;

&lt;p&gt;Our cost story was equally unhelpful at first. We added a contractor to cover the observability backlog, and monthly cloud spend rose by $2,840 before it fell. The contractor found duplicated OpenSearch ingestion and an abandoned Datadog log pipeline, but the cleanup took six weeks and required running old and new paths together. The early finance report showed only a person costing money and a larger AWS bill. By July, the recurring spend was down by roughly $1,900 a month. Velocity did not explain any of that.&lt;/p&gt;

&lt;p&gt;We still estimate. Planning with no rough sizing tends to turn every ticket into a philosophical dispute. But we keep points inside the team, refuse comparisons between teams, and do not put velocity in hiring cases. A hiring decision based on story points is one meeting away from measuring engineers in bananas.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Scrum Needs Someone To Protect The Team”
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Verdict: mostly true. We wish it were less true.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We have seen teams say they are self-managing, then accept every Slack request, executive question, customer escalation, and “quick” production change because nobody has the standing to say no. The result is a sprint plan that exists mainly to be apologised for on the final Friday.&lt;/p&gt;

&lt;p&gt;Someone has to protect the time reserved for the work the team agreed mattered. In our case, that person is usually the engineering manager, but it is not exclusively their job. The rotating facilitator flags new requests during daily Scrum. The on-call engineer can redirect a non-urgent ask into the intake queue. Staff engineers push back on technically dangerous shortcuts. Protection works better when it is shared, because a manager in a meeting cannot stop a drive-by request in a Slack thread.&lt;/p&gt;

&lt;p&gt;Still, a named person needs enough authority to resolve the hard cases. Last month, Sales wanted a one-off IP allowlist for a prospect’s proof of concept. The request arrived eight business days before a security hardening deadline. The technical change would have taken perhaps two hours. The review, exception handling, expiry process, and later cleanup would not. Our manager said no until the Sprint Goal was met. The prospect did not vanish. The certificate work finished on time.&lt;/p&gt;

&lt;p&gt;This only held because our director backed the decision. On teams where every request can be escalated around the manager, “protect the team” becomes a polite phrase for losing an argument more slowly.&lt;/p&gt;

&lt;p&gt;We do not need a full-time Scrum Master for eight engineers. We do need somebody prepared to tell a vice president that their urgent request is a backlog item until they can explain what it displaces.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>scrum</category>
    </item>
    <item>
      <title>Kubernetes NetworkPolicy Rules We Can Actually Prove</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Sun, 23 Aug 2026 13:29:25 +0000</pubDate>
      <link>https://dev.to/devops_oasis/kubernetes-networkpolicy-rules-we-can-actually-prove-1od1</link>
      <guid>https://dev.to/devops_oasis/kubernetes-networkpolicy-rules-we-can-actually-prove-1od1</guid>
      <description>&lt;p&gt;&lt;em&gt;Build a locked-down namespace, then test every allowed connection ourselves.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;orders-api&lt;/code&gt; namespace was accepting traffic from a forgotten batch job at 08:46, which was annoying because we had already written “internal only” in three tickets. Kubernetes had done exactly what we told it to do: nothing. Pods can generally talk to other pods unless the network plugin says otherwise.&lt;/p&gt;

&lt;p&gt;We fixed the small version of that problem first. By the end of this walkthrough, we’ll have an &lt;code&gt;orders-api&lt;/code&gt; namespace where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the API accepts port 8080 traffic only from its frontend;&lt;/li&gt;
&lt;li&gt;the API can reach PostgreSQL on 5432;&lt;/li&gt;
&lt;li&gt;the API can resolve DNS;&lt;/li&gt;
&lt;li&gt;random pods cannot connect to the API or send arbitrary outbound traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This uses standard &lt;a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/" rel="noopener noreferrer"&gt;&lt;code&gt;NetworkPolicy&lt;/code&gt;&lt;/a&gt; objects. We tested the same manifests on Kubernetes 1.30 with Cilium 1.16. Calico works too, but do not assume every cluster’s CNI enforces policies. Kubernetes will happily accept a NetworkPolicy object that changes precisely nothing. A very Kubernetes outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check Whether The Cluster Enforces NetworkPolicy
&lt;/h2&gt;

&lt;p&gt;First, point &lt;code&gt;kubectl&lt;/code&gt; at a cluster we can safely alter.&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;# Confirm the current context before creating anything.&lt;/span&gt;
kubectl config current-context

&lt;span class="c"&gt;# Confirm the Kubernetes server version.&lt;/span&gt;
kubectl version

&lt;span class="c"&gt;# See which CNI components are running.&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On our clusters, Cilium pods show up as &lt;code&gt;cilium-xxxxx&lt;/code&gt; in &lt;code&gt;kube-system&lt;/code&gt;. If we’re using Calico, we expect &lt;code&gt;calico-node-xxxxx&lt;/code&gt;. The exact pod names are less interesting than whether the plugin supports policy enforcement.&lt;/p&gt;

&lt;p&gt;Check the plugin documentation rather than trusting a chart value from six months ago. The relevant references are &lt;a href="https://docs.cilium.io/en/stable/security/policy/kubernetes/" rel="noopener noreferrer"&gt;Cilium’s Kubernetes NetworkPolicy docs&lt;/a&gt; and Calico’s policy documentation.&lt;/p&gt;

&lt;p&gt;For a disposable local cluster, we use &lt;a href="https://kind.sigs.k8s.io/" rel="noopener noreferrer"&gt;kind&lt;/a&gt; with Cilium installed separately. This is optional if there’s already a policy-capable cluster available.&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;# Create a local Kubernetes cluster for the exercise.&lt;/span&gt;
kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; networkpolicy-lab

&lt;span class="c"&gt;# Install the Cilium CLI if it is not already installed.&lt;/span&gt;
&lt;span class="c"&gt;# macOS example:&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;cilium

&lt;span class="c"&gt;# Install Cilium and wait until its agents are ready.&lt;/span&gt;
cilium &lt;span class="nb"&gt;install
&lt;/span&gt;cilium status &lt;span class="nt"&gt;--wait&lt;/span&gt;

&lt;span class="c"&gt;# Point kubectl at the new kind cluster.&lt;/span&gt;
kubectl config use-context kind-networkpolicy-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don’t move past this step on faith. A policy test on a cluster with no enforcing CNI gives us a false sense of safety, which is worse than no test because somebody will cite it during an incident review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create The Three Workloads We Need
&lt;/h2&gt;

&lt;p&gt;We need an API, a frontend client, and a PostgreSQL-shaped target. The database is only &lt;code&gt;postgres:16-alpine&lt;/code&gt;; we are testing network paths, not writing an orders system before lunch.&lt;/p&gt;

&lt;p&gt;Save this as &lt;code&gt;00-workloads.yaml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 00-workloads.yaml&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespace&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;orders-api&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;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;commerce&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/http-echo:1.0&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;-listen=:8080"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-text=orders-api&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;alive"&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&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;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&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;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="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;frontend&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&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;curlimages/curl:8.10.1&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3600"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="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;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;not-a-real-password&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&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;span class="na"&gt;targetPort&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;Apply it and wait for the deployments.&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;# Create the namespace, deployments, and Services.&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; 00-workloads.yaml

&lt;span class="c"&gt;# Wait for all three deployments to become available.&lt;/span&gt;
kubectl rollout status deployment/api &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api
kubectl rollout status deployment/frontend &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api
kubectl rollout status deployment/postgres &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api

&lt;span class="c"&gt;# Keep this output around; labels drive the policy selectors later.&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api &lt;span class="nt"&gt;--show-labels&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We deliberately use workload labels such as &lt;code&gt;app: frontend&lt;/code&gt; rather than pod names. Pod names change. Labels are the contract here, so they deserve the same review attention as an IAM role binding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove The Namespace Starts Wide Open
&lt;/h2&gt;

&lt;p&gt;Before adding any policy, both the frontend and an unapproved pod should reach the API. Create the unapproved pod in a separate namespace so we prove that cross-namespace traffic is also open.&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;# Create a namespace representing an unrelated workload.&lt;/span&gt;
kubectl create namespace reporting

&lt;span class="c"&gt;# Start a temporary curl pod and leave it alive for testing.&lt;/span&gt;
kubectl run reporter &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; reporting &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;curlimages/curl:8.10.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;3600

&lt;span class="c"&gt;# Wait until the test pod has an IP address.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; reporting &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Ready pod/reporter &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;90s

&lt;span class="c"&gt;# Confirm the approved frontend can call the API.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/frontend &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 3 &lt;span class="nt"&gt;-sS&lt;/span&gt; http://api.orders-api.svc.cluster.local:8080

&lt;span class="c"&gt;# Confirm the unrelated reporter can also call it before policy exists.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; reporting pod/reporter &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 3 &lt;span class="nt"&gt;-sS&lt;/span&gt; http://api.orders-api.svc.cluster.local:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both commands should print &lt;code&gt;orders-api is alive&lt;/code&gt;. If the second one already fails, stop and find out why. Perhaps there is an existing global policy, a service mesh rule, or someone has been more diligent than we expected. Copying a baseline test from a blog into a cluster with unknown controls is how we manufacture confusing evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deny All Inbound Traffic To The API
&lt;/h2&gt;

&lt;p&gt;NetworkPolicy becomes active for a pod in a direction when a policy selects that pod and includes that direction. This policy selects API pods and says all ingress is denied unless another policy permits it.&lt;/p&gt;

&lt;p&gt;Save this as &lt;code&gt;01-api-default-deny.yaml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 01-api-default-deny.yaml&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;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-deny-all-ingress&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;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply it, then repeat both checks.&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;# Turn on ingress isolation for API pods.&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; 01-api-default-deny.yaml

&lt;span class="c"&gt;# The frontend should now fail with a connection timeout.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/frontend &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 3 &lt;span class="nt"&gt;-sS&lt;/span&gt; http://api.orders-api.svc.cluster.local:8080

&lt;span class="c"&gt;# The unrelated namespace should fail too.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; reporting pod/reporter &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 3 &lt;span class="nt"&gt;-sS&lt;/span&gt; http://api.orders-api.svc.cluster.local:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The curl exit code should be non-zero, usually &lt;code&gt;28&lt;/code&gt; for a timeout. A rejected TCP connection can also happen depending on the CNI. What we should not see is the response body.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keep the timeout short in these checks. A default curl timeout during a failed policy test feels like waiting for a kettle that has been unplugged.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the shortest section because the object itself is mercifully small. The next policy puts back exactly one route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Allow Only Frontend Requests On Port 8080
&lt;/h2&gt;

&lt;p&gt;The API needs inbound HTTP from frontend pods in the same namespace. We specify both the namespace selector and the pod selector. Omitting &lt;code&gt;namespaceSelector&lt;/code&gt; is easy here because the policy lives in &lt;code&gt;orders-api&lt;/code&gt;, but we prefer being explicit in security controls. It makes a later copy into another namespace less surprising.&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;# 02-api-allow-frontend.yaml&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;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-frontend-http&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;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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="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;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;orders-api&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;frontend&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply it and run the same calls again.&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;# Permit the frontend-to-API path only.&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; 02-api-allow-frontend.yaml

&lt;span class="c"&gt;# This should return the API response now.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/frontend &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 3 &lt;span class="nt"&gt;-sS&lt;/span&gt; http://api.orders-api.svc.cluster.local:8080

&lt;span class="c"&gt;# This must remain blocked.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; reporting pod/reporter &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 3 &lt;span class="nt"&gt;-sS&lt;/span&gt; http://api.orders-api.svc.cluster.local:8080

&lt;span class="c"&gt;# Inspect the effective intent in Kubernetes, not just the local file.&lt;/span&gt;
kubectl describe networkpolicy &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api api-allow-frontend-http
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Policies are additive. The deny policy does not override the allow policy; it establishes isolation, and this policy adds a permitted ingress path. We still see people expect “deny all” to win like a firewall rule at the bottom of a list. Kubernetes NetworkPolicy does not work that way, and frankly I prefer the additive model once everyone has been burned by it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restrict API Egress To PostgreSQL And DNS
&lt;/h2&gt;

&lt;p&gt;Now we isolate egress from the API. This catches the overlooked half of many namespace policies: an exploited API that can call every service and every public endpoint is still having a fairly productive day.&lt;/p&gt;

&lt;p&gt;First, find the DNS service IP and labels in this cluster. CoreDNS commonly has &lt;code&gt;k8s-app: kube-dns&lt;/code&gt;, but check rather than inventing labels at runtime.&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;# Find the DNS service and its ClusterIP.&lt;/span&gt;
kubectl get service &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system kube-dns

&lt;span class="c"&gt;# Inspect CoreDNS pod labels for the policy selector.&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;-l&lt;/span&gt; k8s-app&lt;span class="o"&gt;=&lt;/span&gt;kube-dns &lt;span class="nt"&gt;--show-labels&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the egress policy. The PostgreSQL rule permits TCP/5432 to pods labeled &lt;code&gt;app: postgres&lt;/code&gt; in &lt;code&gt;orders-api&lt;/code&gt;. The DNS rule permits UDP and TCP port 53 to CoreDNS pods in &lt;code&gt;kube-system&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 03-api-egress.yaml&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;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-postgres-and-dns&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;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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;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;orders-api&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;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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Enable egress isolation and allow only database plus DNS traffic.&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; 03-api-egress.yaml

&lt;span class="c"&gt;# DNS resolution should still work from the API pod.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/api &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  wget &lt;span class="nt"&gt;-qO-&lt;/span&gt; http://postgres.orders-api.svc.cluster.local:5432 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# TCP connectivity to PostgreSQL should succeed, even though HTTP is nonsense there.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/api &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'nc -zvw 3 postgres.orders-api.svc.cluster.local 5432'&lt;/span&gt;

&lt;span class="c"&gt;# Arbitrary outbound HTTPS should now time out.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/api &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  wget &lt;span class="nt"&gt;-T&lt;/span&gt; 3 &lt;span class="nt"&gt;-qO-&lt;/span&gt; https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first command may emit PostgreSQL protocol noise because &lt;code&gt;wget&lt;/code&gt; is speaking HTTP to a database. That is expected and mildly ugly. The &lt;code&gt;nc&lt;/code&gt; command is the useful test; it should report a successful connection.&lt;/p&gt;

&lt;p&gt;We have not tried this exact label-based DNS rule past 40 nodes with NodeLocal DNSCache enabled. That setup changes the destination path, and we still have an open argument about whether to standardise it this quarter. If NodeLocal DNSCache is in use, test it from a real pod before declaring DNS allowed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add A Default Deny For New Pods
&lt;/h2&gt;

&lt;p&gt;The API is isolated, but a new pod added to &lt;code&gt;orders-api&lt;/code&gt; would still have open ingress and egress unless its own policy selects it. We add namespace-wide default denial so new workloads begin closed.&lt;/p&gt;

&lt;p&gt;This is our preference for service namespaces. It forces each deployment PR to state its network needs. It also creates more policy files, which is a fine trade when the alternative is guessing why a maintenance job can reach payroll.&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;# 04-namespace-default-deny.yaml&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;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;namespace-default-deny&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;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Apply isolation to every current and future pod in orders-api.&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; 04-namespace-default-deny.yaml

&lt;span class="c"&gt;# The frontend now has no egress policy, so its API call should fail.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/frontend &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 3 &lt;span class="nt"&gt;-sS&lt;/span&gt; http://api.orders-api.svc.cluster.local:8080

&lt;span class="c"&gt;# List every policy now affecting the namespace.&lt;/span&gt;
kubectl get networkpolicy &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That failed frontend call is correct. We broke it deliberately, in daylight, while we have the terminal and the attention span to repair it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore The Frontend’s Required Egress
&lt;/h2&gt;

&lt;p&gt;The frontend needs DNS and TCP/8080 to the API. Add those two paths, then rerun the test. We use one policy per workload rather than one huge namespace policy because code review stays readable after the fourth service arrives.&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;# 05-frontend-egress.yaml&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;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;frontend-allow-api-and-dns&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;orders-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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;frontend&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;orders-api&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;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="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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Restore only the frontend's required outbound paths.&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; 05-frontend-egress.yaml

&lt;span class="c"&gt;# Approved frontend traffic works again.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/frontend &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 3 &lt;span class="nt"&gt;-sS&lt;/span&gt; http://api.orders-api.svc.cluster.local:8080

&lt;span class="c"&gt;# Cross-namespace traffic remains blocked.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; reporting pod/reporter &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 3 &lt;span class="nt"&gt;-sS&lt;/span&gt; http://api.orders-api.svc.cluster.local:8080

&lt;span class="c"&gt;# Show all objects we would commit with the application manifests.&lt;/span&gt;
kubectl get networkpolicy &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, we would commit the five YAML files beside the Helm chart or Kustomize overlay that owns &lt;code&gt;orders-api&lt;/code&gt;. Keeping policies in a separate security repository sounds tidy until an application team changes its port and the policy change waits in another queue. We tried that arrangement in 2024. It produced a lot of polite Slack messages and one very long release.&lt;/p&gt;

&lt;h2&gt;
  
  
  When This Goes Sideways
&lt;/h2&gt;

&lt;p&gt;If every connection still works after applying &lt;code&gt;api-deny-all-ingress&lt;/code&gt;, the CNI probably is not enforcing NetworkPolicy. Check its DaemonSet, its logs, and any managed-cluster setting that enables policy support. &lt;code&gt;kubectl get networkpolicy&lt;/code&gt; only proves the API server stored YAML.&lt;/p&gt;

&lt;p&gt;If DNS fails after egress isolation, inspect the actual CoreDNS labels and check whether NodeLocal DNSCache is enabled:&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;# Check CoreDNS labels and look for NodeLocal DNSCache.&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;--show-labels&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'coredns|node-local-dns'&lt;/span&gt;

&lt;span class="c"&gt;# Inspect DNS configuration from the affected pod.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/api &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /etc/resolv.conf

&lt;span class="c"&gt;# Review policy events and the API pod's recent state.&lt;/span&gt;
kubectl describe pod &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api
kubectl get events &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.lastTimestamp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a policy seems correct but traffic still fails, test by pod IP as well as Service DNS. Service handling varies by CNI mode, kube-proxy replacement, and whether traffic is observed before or after translation. We do not guess which one applies; we record the cluster version and CNI mode in the incident ticket.&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;# Get the PostgreSQL pod IP for a direct connectivity comparison.&lt;/span&gt;
&lt;span class="nv"&gt;POSTGRES_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get pod &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].status.podIP}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Test direct TCP traffic from the API pod.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api deploy/api &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"nc -zvw 3 &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;POSTGRES_IP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; 5432"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, remove the lab when it has served its purpose. Leave it around and somebody will point a real test at &lt;code&gt;postgres&lt;/code&gt; because the service name looked convenient.&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;# Remove the exercise namespaces and all namespaced resources.&lt;/span&gt;
kubectl delete namespace orders-api reporting

&lt;span class="c"&gt;# Delete the local kind cluster if we created one.&lt;/span&gt;
kind delete cluster &lt;span class="nt"&gt;--name&lt;/span&gt; networkpolicy-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>devops</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>DevOps Questions After We Broke The Release Handshake</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Sat, 22 Aug 2026 06:07:26 +0000</pubDate>
      <link>https://dev.to/devops_oasis/devops-questions-after-we-broke-the-release-handshake-46j2</link>
      <guid>https://dev.to/devops_oasis/devops-questions-after-we-broke-the-release-handshake-46j2</guid>
      <description>&lt;p&gt;&lt;em&gt;Answers from the incident where every dashboard looked politely wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;release-api&lt;/code&gt; deployment had already been marked complete when the invoice page began returning 503s. The new container was serving traffic, the PostgreSQL migration had committed, and the feature flag was on. A &lt;code&gt;NetworkPolicy&lt;/code&gt; added in another repository prevented the new pod from reaching &lt;code&gt;tax-rate-cache&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The application team saw errors, the database team saw a clean migration, and Platform saw green nodes. By the time we put all three facts in one incident channel, 63 deployment messages had buried the one that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Is This Actually A DevOps Failure Or Just One Bad Deploy?”
&lt;/h2&gt;

&lt;p&gt;It was a DevOps failure because four teams completed valid local work and nobody owned the release handoff between them.&lt;/p&gt;

&lt;p&gt;Calling it “just a bad deploy” would have been convenient. We could have fixed the policy, replayed the release, written a short incident note, and carried on pretending that a green Argo CD application means a service is ready for users. The pod was healthy according to Kubernetes. It was also unable to call a dependency required to render an invoice. Both things can be true, which is why a deployment status alone is a fairly poor witness.&lt;/p&gt;

&lt;p&gt;Our old release process had hidden contracts in too many places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The service repository declared its image and Helm values.&lt;/li&gt;
&lt;li&gt;The infrastructure repository held network rules.&lt;/li&gt;
&lt;li&gt;Database migrations ran from a separate GitHub Actions workflow.&lt;/li&gt;
&lt;li&gt;Feature flags lived in LaunchDarkly, owned by whoever had last touched the feature.&lt;/li&gt;
&lt;li&gt;The runbook lived in Confluence, where it had last been edited in February.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We’ve started putting the release dependencies in the service repository, close to the code that needs them. It is not a clever system. It is a file that a human can read during an incident and a pipeline can check before promotion.&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;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release-api&lt;/span&gt;
  &lt;span class="na"&gt;requires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dependency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tax-rate-cache&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;finance&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;network_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow-release-api-to-tax-rate-cache&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;migration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026_08_17_add_tax_region&lt;/span&gt;
      &lt;span class="na"&gt;minimum_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;4.12.0"&lt;/span&gt;
  &lt;span class="na"&gt;feature_flags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;invoice_tax_v2&lt;/span&gt;
  &lt;span class="na"&gt;smoke_test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./scripts/check-invoice-preview.sh"&lt;/span&gt;
    &lt;span class="na"&gt;timeout_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;90&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline does not yet validate every field. It checks that the named NetworkPolicy exists in the target cluster and runs the smoke test after the migration. That alone would have caught Monday’s problem before 11% of invoice-page requests failed.&lt;/p&gt;

&lt;p&gt;We dislike using “DevOps” as a department name. In this case, it means the work of making handoffs visible before production discovers them for us.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Do Developers Really Need Production Access?”
&lt;/h2&gt;

&lt;p&gt;Yes, but they need narrow production access tied to the service they own, not a shared &lt;code&gt;kubectl&lt;/code&gt; credential passed around like an office spare key.&lt;/p&gt;

&lt;p&gt;For too long, our answer was to give engineers log access and tell them to ask Platform for anything else. That worked while we had six services and one cluster. We now have 34 services, three production clusters, and an on-call rota where the person holding the pager may know the application code better than the person who built the cluster.&lt;/p&gt;

&lt;p&gt;The useful access is usually boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read logs, events, and deployment state in the team namespace.&lt;/li&gt;
&lt;li&gt;View configured secrets by name, never by value.&lt;/li&gt;
&lt;li&gt;Restart a failed deployment through a recorded workflow.&lt;/li&gt;
&lt;li&gt;Fetch an approved diagnostic profile during an incident.&lt;/li&gt;
&lt;li&gt;Read relevant cloud metrics and traces.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The access we do not hand out includes cluster-wide secret reads, direct production database writes, and unrestricted &lt;code&gt;exec&lt;/code&gt; into pods. There are rare reasons for each. Those reasons deserve an incident ticket and a second human looking at the command.&lt;/p&gt;

&lt;p&gt;Our Kubernetes groups now map to service ownership. The &lt;code&gt;catalog&lt;/code&gt; team cannot casually inspect &lt;code&gt;identity&lt;/code&gt;, and Platform cannot claim every namespace is somebody else’s concern.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;catalog-oncall&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;catalog-prod&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events"&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="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;apps"&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;deployments"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replicasets"&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patch"&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/exec"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The empty &lt;code&gt;pods/exec&lt;/code&gt; permission is deliberate. We had one incident in May where an engineer fixed a live configuration file inside a container, then forgot it existed. The next rollout erased it, as containers are designed to do. We spent forty minutes rediscovering the same failure with more coffee involved.&lt;/p&gt;

&lt;p&gt;Kubernetes has plenty of sharp edges here, so we keep the policies close to the &lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/rbac/" rel="noopener noreferrer"&gt;upstream RBAC model&lt;/a&gt; rather than inventing an internal permissions language.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Why Can’t Platform Just Own Every Deployment?”
&lt;/h2&gt;

&lt;p&gt;Platform can own the deployment path; it cannot own the operational knowledge of every service.&lt;/p&gt;

&lt;p&gt;We tested the “Platform approves production” model last autumn. It looked safe on a process diagram. In practice, it put two platform engineers between every product team and a release, including changes that only altered invoice wording or a CSS bundle. The queue grew, people started sending urgent messages, and approval became a ritual performed after somebody had already decided to deploy.&lt;/p&gt;

&lt;p&gt;That is gatekeeping with better YAML.&lt;/p&gt;

&lt;p&gt;The service team now owns whether its code should go live. Platform owns the tooling that makes the decision visible, repeatable, and reversible: the GitHub Actions templates, the Argo CD projects, the cluster policies, and the audit trail. Security owns the controls that must be true before the workflow can issue production credentials. Those boundaries are written down because informal agreement lasts exactly until the person who remembers it is on holiday.&lt;/p&gt;

&lt;p&gt;We use &lt;code&gt;CODEOWNERS&lt;/code&gt; for files that change shared deployment behavior. A service team can change its replica count or an application environment variable. A change to a shared ingress class, an organisation-level action, or a production NetworkPolicy requires Platform review.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .github/CODEOWNERS
/.github/workflows/reusable-deploy.yml  @platform-engineering
/platform/network-policies/            @platform-engineering @security-engineering
/services/catalog/                     @catalog-team
/services/release-api/                 @billing-team
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub documents the awkward parts of CODEOWNERS matching and branch protection, including the fact that a file can have one visible owner line while several teams assume they own it. We found two of those in the first review.&lt;/p&gt;

&lt;p&gt;The rule is not “Platform stays out of releases.” We still join risky database changes, regional failovers, and anything touching shared ingress. We simply refuse to become the human merge queue for routine service work.&lt;/p&gt;

&lt;h2&gt;
  
  
  “What Does ‘You Build It, You Run It’ Mean On Pager?”
&lt;/h2&gt;

&lt;p&gt;It means the team that changes a service takes first call for its user-facing failures, with Platform covering failures in the shared runtime.&lt;/p&gt;

&lt;p&gt;We had to make that sentence more precise than it sounds. “Run it” had become a polite way to tell application engineers that they owned every alert, including node pressure, expired cluster certificates, and a broken Fluent Bit daemonset. Nobody learns much from a pager at 03:00 that says “the whole platform is vaguely unhappy.”&lt;/p&gt;

&lt;p&gt;Our current split is based on the failing layer. If &lt;code&gt;catalog-api&lt;/code&gt; returns 5xx responses because of a bad release, Catalog gets paged. If every namespace loses DNS resolution, Platform gets paged. If the application cannot reach Redis because its own connection pool is exhausted, Catalog gets paged; if the Redis service itself is unavailable, the data platform rotation gets paged and Catalog gets an incident notification.&lt;/p&gt;

&lt;p&gt;That sounds obvious after writing it down. It was not obvious in PagerDuty, where 19 services had a generic escalation policy called &lt;code&gt;production-critical&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We changed the alert annotation format so it carries an owner, a runbook URL, and a statement of what user action is failing. The last field has reduced some thoroughly unhelpful alerts.&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;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;owner_team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;catalog&lt;/span&gt;
  &lt;span class="na"&gt;runbook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://ops.example.internal/runbooks/catalog-checkout-errors&lt;/span&gt;
  &lt;span class="na"&gt;user_impact&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Customers&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cannot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;add&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;an&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;an&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;existing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;order"&lt;/span&gt;
  &lt;span class="na"&gt;dashboard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://grafana.example.internal/d/catalog-api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mina, who has been on our platform rota longer than the current Grafana folder structure, objected to putting ownership in annotations. Her point was reasonable: labels rot. She was right, so the alert rule now validates the owner against our service catalog during CI. We still have stale runbooks, but now they fail a check instead of waiting quietly for a bad night.&lt;/p&gt;

&lt;p&gt;For alert design, we keep returning to the &lt;a href="https://sre.google/workbook/alerting-on-slos/" rel="noopener noreferrer"&gt;Google SRE Workbook guidance on paging&lt;/a&gt;: page for urgent user impact, not every noisy internal symptom. It has saved us from several clever alerts that nobody could act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Are DORA Metrics Going To Become Another Scorecard?”
&lt;/h2&gt;

&lt;p&gt;They will if we publish team rankings, so we do not.&lt;/p&gt;

&lt;p&gt;We track deployment frequency, lead time, change failure rate, and time to restore because they help us locate friction in the delivery system. The &lt;a href="https://dora.dev/guides/dora-metrics-four-keys/" rel="noopener noreferrer"&gt;DORA research&lt;/a&gt; is useful as a vocabulary. It does not grant permission to declare that the team with fewer deployments is failing.&lt;/p&gt;

&lt;p&gt;Our billing team deploys &lt;code&gt;release-api&lt;/code&gt; three or four times a week. The reporting team deploys its batch export service about twice a month, often after waiting for a partner’s sample file. Comparing those numbers is managerial astrology.&lt;/p&gt;

&lt;p&gt;Instead, we review metric changes against specific events. In July, median lead time for Catalog rose from 7 hours to 31 hours. The cause was not developer speed. A required image scan had begun waiting behind the same two self-hosted runners that handled integration tests. We added two runners, separated scan jobs from test jobs, and lead time dropped the following week.&lt;/p&gt;

&lt;p&gt;Change failure rate exposed a less comfortable issue in Billing. Their rate was 18% over six weeks, mostly because a database migration and application release were approved separately. The failed &lt;code&gt;release-api&lt;/code&gt; deployment belongs in that number. We are not using it to tell the team to make fewer changes; we are using it to fund the work of joining the changes safely.&lt;/p&gt;

&lt;p&gt;We show rolling twelve-week trends and attach incident links to failed changes. Nobody gets a traffic-light score. If a metric appears in a performance review, people will optimise the number and hide the problem. We have enough experience with that particular species of spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Should We Put Argo Rollouts In Front Of Every Service?”
&lt;/h2&gt;

&lt;p&gt;We don’t know yet, honestly.&lt;/p&gt;

&lt;p&gt;We have Argo Rollouts running for three HTTP services: &lt;code&gt;catalog-api&lt;/code&gt;, &lt;code&gt;release-api&lt;/code&gt;, and &lt;code&gt;notifications-webhook&lt;/code&gt;. The canary step sends 10% of traffic to the new ReplicaSet, waits five minutes, then checks request error rate and p95 latency. If either crosses the configured threshold, it aborts.&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;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;canary&lt;/span&gt;&lt;span class="pi"&gt;:&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;setWeight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pause&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;analysis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;templates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;templateName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-http-health&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pause&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;release-api&lt;/code&gt; incident would probably have failed its canary analysis. “Probably” matters. Our staging traffic is too thin to produce a meaningful request-error signal, and production canaries only help when the issue appears in the first slice of traffic. A missing network permission would have done so. A quarterly invoice batch failure would not.&lt;/p&gt;

&lt;p&gt;We have not tried it past 40 nodes, and our production clusters have 27, 31, and 38 worker nodes. The controller overhead looks modest in our tests. That is not evidence for what happens after the acquisition team’s services arrive, assuming that deal survives the budget meeting.&lt;/p&gt;

&lt;p&gt;We also do not want rollout machinery around every workload. A nightly reconciliation Job does not need traffic shifting. Neither does a CronJob that fetches exchange rates at 04:15. For these, a failed run needs a clear alert and an easy rollback of the image tag. Adding canary objects would make the repository busier without reducing the relevant risk.&lt;/p&gt;

&lt;p&gt;Argo’s &lt;a href="https://argo-rollouts.readthedocs.io/en/stable/features/analysis/" rel="noopener noreferrer"&gt;analysis templates&lt;/a&gt; are useful because they force us to state what “healthy” means. The hard part is choosing a metric that reflects users rather than deployment optimism.&lt;/p&gt;

&lt;h2&gt;
  
  
  “How Will We Know The New Release Rules Are Working?”
&lt;/h2&gt;

&lt;p&gt;We’ll know when a service team can explain a failed production release from one pull request, one deployment record, and one incident timeline.&lt;/p&gt;

&lt;p&gt;That is our test for the next six weeks. Each production deployment must link its commit, container digest, migration version, feature flags, smoke-test result, and rollback action. We are adding those fields to the release event emitted into OpenTelemetry, then building the incident view from that event instead of asking people to paste screenshots into Slack.&lt;/p&gt;

&lt;p&gt;The first version will be ugly. It only held together this week because the billing team already had consistent Git tags and the migration workflow happened to emit a version number. Catalog does not yet do either. We are fixing the release metadata there before we announce a company-wide standard and discover that standards are easiest when somebody else has done the plumbing.&lt;/p&gt;

&lt;p&gt;We do not need a 64th deployment message.&lt;/p&gt;

&lt;p&gt;The remaining argument is whether a NetworkPolicy change should block a service release until its owning team approves it, or whether the platform team should approve it as shared infrastructure. We have put that decision on Thursday’s architecture agenda, where it will receive the traditional amount of adult supervision.&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Kanban After 12 Months Of Interrupt-Driven Work</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Sat, 22 Aug 2026 05:41:07 +0000</pubDate>
      <link>https://dev.to/devops_oasis/kanban-after-12-months-of-interrupt-driven-work-5611</link>
      <guid>https://dev.to/devops_oasis/kanban-after-12-months-of-interrupt-driven-work-5611</guid>
      <description>&lt;p&gt;&lt;em&gt;One year after replacing sprints, our platform team kept the limits and lost the theatre.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On 19 August 2025, we deleted the remaining two-week sprint from our platform board while seven tickets were still marked “in progress.” Two were waiting on product teams, one was a certificate renewal nobody had planned, and one was a fairly ordinary Terraform change that had somehow acquired four assignees.&lt;/p&gt;

&lt;p&gt;We had spent six months pretending this was a planning problem. It was an interruption problem.&lt;/p&gt;

&lt;p&gt;Our nine-person platform team supports 36 production services, a shared Kubernetes cluster, identity, CI runners, and the collection of internal tools that only become visible when they fail at 03:00. Sprints made the work look orderly for roughly a day and a half. Then an incident, an access request, or a release deadline arrived and the board became historical fiction.&lt;/p&gt;

&lt;p&gt;We moved to kanban with a hard WIP limit, an expedite lane, and a promise to stop calling unfinished work “carryover” as if it had merely missed a train. One year later, we’d grade the decision a B. The board changed how we start work and how we argue. It also created chores we had not put on anybody’s capacity plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Expected The Board To Expose Hidden Work
&lt;/h2&gt;

&lt;p&gt;The expected gain was embarrassingly simple: show the work we were already doing.&lt;/p&gt;

&lt;p&gt;Before kanban, an engineer could have a sprint ticket, two Slack requests, a deployment rollback, and a “quick look” at a customer’s IAM policy. Only the sprint ticket appeared in the report sent to leadership on Friday. We were measuring planned work against a week that contained a great deal of unplanned work, then acting surprised when the ratio looked bad.&lt;/p&gt;

&lt;p&gt;We expected WIP limits to make people finish things before picking up more things. Our original rule was eight active standard tickets across the team, with one separate expedite slot. Eight felt generous. It was based on the number of engineers minus the people we assumed would be in meetings, which is not a calculation we’d recommend preserving in a museum.&lt;/p&gt;

&lt;p&gt;We also expected the classes of service to stop being decorative labels. “Expedite” meant production impact or a security deadline with fewer than five days left. “Fixed date” meant a date imposed by something outside our team: a contract renewal, audit evidence, a vendor certificate expiry. Everything else entered the standard queue.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://kanbanguides.org/english/" rel="noopener noreferrer"&gt;Kanban Guide&lt;/a&gt; is admirably plain about starting with the current way of working. We took that seriously, perhaps because we did not have enough spare energy to redesign the department and install a new board in the same quarter.&lt;/p&gt;

&lt;p&gt;Our old sprint plan behaved like a railway timetable after a signal failure: technically still present, increasingly irrelevant.&lt;/p&gt;

&lt;p&gt;The first version had three columns: Ready, Doing, Done. We added Blocked on the second day because “Doing” was hiding work that no one could move. That should have told us something about the first version’s intellectual depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuesday Replenishment Changed More Than Stand-Up
&lt;/h2&gt;

&lt;p&gt;What actually happened was quieter than we expected. Nobody stood up in a meeting and declared a new era of flow. The useful shift came on the first Tuesday when the Ready column held 19 tickets and Doing had reached its limit.&lt;/p&gt;

&lt;p&gt;We had 11 active tickets. More precisely, we had 11 tickets marked active; three were waiting for a reply, two had not been touched since the previous week, and one was a task to “investigate service mesh options,” which is a sentence that should trigger an automatic timeout.&lt;/p&gt;

&lt;p&gt;The team started asking a different question: what can we finish before taking this? That sounds obvious when written down. In practice, it changed the order of conversations. An engineer working on an OpenTofu module asked the service owner for the missing variable list before beginning a new request. Another closed a stale upgrade card after confirming the vendor had withdrawn the affected release. We stopped treating context switching as evidence of responsiveness.&lt;/p&gt;

&lt;p&gt;Our daily check-in dropped from 25 minutes to around 11. We walk the board right to left, starting with blocked items and then work closest to done. We do not go person by person. The person-by-person version had become a polite recital of activity, and everyone knew it.&lt;/p&gt;

&lt;p&gt;Throughput settled around 14 completed standard tickets per fortnight, though the number is lumpy enough that we do not use it for individual targets. In October, we completed 23 because several old requests were finally closed. In January, we completed six while rebuilding the GitHub Actions runner group after an image change broke our ARM jobs.&lt;/p&gt;

&lt;p&gt;Little’s Law gave us a useful way to discuss that variation: more work in progress generally means longer time to finish, assuming the system is reasonably stable. The formula is short; the hard part is refusing to add “just one more” item when the board is full. The explanation of Little’s Law is more useful than most delivery dashboards we’ve purchased.&lt;/p&gt;

&lt;p&gt;This only held because our engineering managers agreed not to insert work directly into Doing. They can request an expedite item. They cannot assign it a person and declare the WIP limit an inconvenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Blocked Column Made Dependencies Public
&lt;/h2&gt;

&lt;p&gt;The surprise nobody predicted was that blocked work would become mildly political.&lt;/p&gt;

&lt;p&gt;A ticket marked Blocked tells a different story from a ticket left in Doing. It tells us the platform team is waiting on a security approval, a product decision, or a vendor reply. That visibility is useful, but it also names the place where work has stopped. Some teams welcomed it. One product group asked whether we could use a softer status because “blocked sounds accusatory.”&lt;/p&gt;

&lt;p&gt;We did not.&lt;/p&gt;

&lt;p&gt;We did change the policy. Every blocked ticket now needs a short reason, the name of the waiting party, and a next follow-up date. “Waiting on product” is not enough. “Waiting on Checkout to choose whether webhook retries should be at-least-once or deduplicated; follow up Thursday” is enough.&lt;/p&gt;

&lt;p&gt;By November, the board showed an uncomfortable pattern. Access work was not slow because creating roles in Okta took long. It was slow because requesters submitted a ticket before deciding which people needed access and why. We had built a form that asked for an application name and environment, then left the actual approval chain to comments.&lt;/p&gt;

&lt;p&gt;That led to a small but worthwhile change: access requests now require an owner and an expiry date before entering Ready. The number of access tickets did not fall. Their median elapsed time fell from 9.4 business days to 3.1, mostly because we stopped accepting half-formed requests.&lt;/p&gt;

&lt;p&gt;The blocked data also gave us evidence during the February incident review. We could show that six “urgent” observability requests had waited on service owners to define an SLO. The &lt;a href="https://sre.google/workbook/alerting-on-slos/" rel="noopener noreferrer"&gt;Google SRE workbook’s alerting guidance&lt;/a&gt; says alerts should connect to a user-facing objective. We had been asking teams for exactly that, then allowing the cards to enter the queue without it.&lt;/p&gt;

&lt;p&gt;There is still a social cost. People dislike seeing their team named in a blocked reason, even when the wording is factual. We have not found a better answer than being precise and not turning the board into a blame register.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Board Needed Someone To Weed It
&lt;/h2&gt;

&lt;p&gt;Nobody budgeted for board maintenance.&lt;/p&gt;

&lt;p&gt;Not administration in the abstract. Actual maintenance: closing dead cards, correcting issue types, checking automations, chasing owners, and making sure a ticket moved to Done reflects a deployed change rather than a pull request waiting for approval.&lt;/p&gt;

&lt;p&gt;For the first three months, GitHub Projects automation handled enough of this that we thought we had escaped the usual board janitor role. A pull request merge moved the linked issue to Done. New issues with the &lt;code&gt;platform&lt;/code&gt; label entered Triage. An &lt;code&gt;incident&lt;/code&gt; label put work into Expedite.&lt;/p&gt;

&lt;p&gt;Then reality arrived. A merged pull request could deploy to staging and sit there for nine days. Incident tickets kept the label after the incident was closed. A request from Finance inherited &lt;code&gt;platform&lt;/code&gt; because someone copied an old issue template. The board was accurate in the same way a kitchen drawer full of old cables is organised: there was a system, once.&lt;/p&gt;

&lt;p&gt;Here is how the setup drifted over the year:&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;# 19 August 2025&lt;/span&gt;
&lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Ready&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Doing&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Done&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;doing_wip_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;
  &lt;span class="na"&gt;expedite_wip_limit&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;done_when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pull&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;merged"&lt;/span&gt;
&lt;span class="na"&gt;automation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;label_platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;add&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Ready"&lt;/span&gt;
  &lt;span class="na"&gt;label_incident&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;move&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Doing"&lt;/span&gt;

&lt;span class="c1"&gt;# 19 August 2026&lt;/span&gt;
&lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Triage&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Ready&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Doing&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Blocked&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Verify&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Done&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;doing_wip_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;
  &lt;span class="na"&gt;expedite_wip_limit&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;blocked_requires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;waiting_on&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;follow_up_date&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;done_when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;verification&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;recorded"&lt;/span&gt;
&lt;span class="na"&gt;policies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;triage_owner_rotation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;weekly&lt;/span&gt;
  &lt;span class="na"&gt;stale_ready_after_days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="na"&gt;expedite_requires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;customer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;impact&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;security&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deadline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;under&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;days"&lt;/span&gt;
&lt;span class="na"&gt;automation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;label_platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;add&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Triage"&lt;/span&gt;
  &lt;span class="na"&gt;label_incident&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;move&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Expedite"&lt;/span&gt;
  &lt;span class="na"&gt;merged_pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;move&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Verify"&lt;/span&gt;
  &lt;span class="na"&gt;verified_production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;move&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Done"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We now rotate a weekly triage owner. It takes about 35 minutes on a normal week and longer after an incident. The owner checks new work, pings stalled cards, and closes tickets that have become archaeology. Nobody loves the rota, but it is far less annoying than discovering during replenishment that Ready contains a request for Ubuntu 20.04 package support from last winter.&lt;/p&gt;

&lt;p&gt;Our on-call rota deserves a brief mention here. We stopped making the on-call engineer triage owner after one week in September, when the same person had a noisy Kafka page at 02:17 and a backlog of 14 intake tickets by breakfast. The board can wait. A tired engineer should not have to decide whether a request is “standard” or “fixed date” before coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Kept Limits And Replenishment, Not Velocity Charts
&lt;/h2&gt;

&lt;p&gt;The pieces we kept are the ones that force a decision.&lt;/p&gt;

&lt;p&gt;The Doing limit is now six, not eight. We lowered it after measuring elapsed time and noticing that work got stuck at six anyway; the extra two slots were simply permission for two more partially started tasks. Six feels restrictive on Monday morning. By Thursday, it usually feels sensible.&lt;/p&gt;

&lt;p&gt;We also kept twice-weekly replenishment. Tuesday and Friday, 20 minutes each, with the triage owner and whichever engineers are free. We pull enough Ready work to keep the board supplied, not enough to make everyone feel booked. We do not estimate every card. If a task is clearly larger than about five working days, we split it or write a short discovery card with a specific decision as its exit condition.&lt;/p&gt;

&lt;p&gt;Our preference is blunt: we dislike story points for platform work. Giving an S3 lifecycle policy a five and a Kubernetes minor upgrade a thirteen produced elaborate certainty theatre. Elapsed time, blocked age, and a short list of current work tell us more.&lt;/p&gt;

&lt;p&gt;We retained a monthly service-delivery review, although we cut the slide deck from 18 slides to four screenshots and a list of the oldest blocked items. We look at completion times by work type, not individual engineer. The point is to see that fixed-date work is clogging the queue or that access requests are incomplete, not to invent a ranking system for people who happened to draw the ugly tickets.&lt;/p&gt;

&lt;p&gt;We have not tried this past 40 nodes or across multiple platform teams. Our cluster has 17 worker nodes and one team owns the board. At a larger scale, shared services and dependency boards may require a different arrangement. We are not claiming a magic ceiling; we simply do not have evidence beyond our own rather manageable mess.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Ripped Out Swimlanes And Deadline Colours
&lt;/h2&gt;

&lt;p&gt;We removed the swimlanes.&lt;/p&gt;

&lt;p&gt;At one point the board had lanes for Kubernetes, CI, cloud accounts, observability, security, and “team enablement.” Cards duplicated across them, work vanished below the fold, and nobody could tell whether the WIP limit applied to the lane or the column. A board that needs a legend has already lost an argument with its users.&lt;/p&gt;

&lt;p&gt;We also removed due-date colours. Red cards encouraged people to work on the nearest date even when the date was invented by a requester who wanted attention. Fixed-date work now has a documented external deadline and a reason. A request for “this sprint if possible” is standard work, even if the ticket title contains three exclamation marks.&lt;/p&gt;

&lt;p&gt;The final thing we ripped out was the rule that every engineer must always have a card assigned. Pairing on a thorny rollout, reviewing a production verification, or helping the on-call engineer is work. It does not become more real because a board has somebody’s avatar attached.&lt;/p&gt;

&lt;p&gt;One argument remains unresolved. Security remediation gets its own expedite slot when there is an active exploit, but we still disagree about high-severity findings with no known exploit. Half the team wants a second reserved slot. The rest of us think that would quietly become a permanent bypass around the limit. The next critical CVE will probably settle it for us.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kanban</category>
    </item>
    <item>
      <title>Leadership Mistakes We Put Into Production Before Lunch</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Sat, 22 Aug 2026 05:40:47 +0000</pubDate>
      <link>https://dev.to/devops_oasis/leadership-mistakes-we-put-into-production-before-lunch-133</link>
      <guid>https://dev.to/devops_oasis/leadership-mistakes-we-put-into-production-before-lunch-133</guid>
      <description>&lt;p&gt;&lt;em&gt;Six management failures we shipped alongside perfectly valid infrastructure changes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At 11:42 on Monday, the &lt;code&gt;billing-export&lt;/code&gt; pull request had four approvals, one frustrated staff engineer, and no merge. Nothing in the diff was controversial. It changed a retry timeout from 30 seconds to 45. The problem was that our repository rules required a platform lead to approve anything touching &lt;code&gt;infra/&lt;/code&gt;, and both platform leads were in a quarterly planning meeting.&lt;/p&gt;

&lt;p&gt;We wrote that rule six months earlier. We then spent three hours discovering what it had actually done to the team.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Made Leads The Default Merge Queue
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What we did
&lt;/h3&gt;

&lt;p&gt;We put &lt;code&gt;@platform-leads&lt;/code&gt; in &lt;code&gt;CODEOWNERS&lt;/code&gt; for most infrastructure paths. Any Terraform module, Kubernetes manifest, Helm chart, or GitHub Actions workflow waited for one of two people.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Bad: .github/CODEOWNERS
/infra/              @platform-leads
/kubernetes/         @platform-leads
/.github/workflows/  @platform-leads
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looked orderly in the repository settings screen. We had senior review on high-impact changes. Nobody could accidentally modify production wiring on a quiet Friday afternoon. The fact that “quiet Friday afternoon” is when people make choices they regret did occur to us.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it looked reasonable
&lt;/h3&gt;

&lt;p&gt;We had just hired three engineers who had not worked with our AWS accounts before. At the same time, a state-lock incident had left an S3 bucket policy too open for 47 minutes. We wanted a brake.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners" rel="noopener noreferrer"&gt;GitHub’s CODEOWNERS rules&lt;/a&gt; make this easy to set up, and easy to overuse. A broad path ownership rule feels like governance because it produces approvals. It doesn’t tell us whether the approval added useful scrutiny.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it cost us
&lt;/h3&gt;

&lt;p&gt;Between 4 and 15 August, 31 pull requests waited longer than two business hours for a required reviewer. Nine were harmless dependency bumps. Six were fixes to alerts already paging people. One was the retry timeout.&lt;/p&gt;

&lt;p&gt;The less obvious cost was learned helplessness. Engineers started asking, “Can you just take this?” before they had read the plan output. Leads got worse at reviewing because our queue had become full of changes too small to deserve deep attention.&lt;/p&gt;

&lt;p&gt;Our review time became an interrupt-driven service, which is a fairly miserable thing to run.&lt;/p&gt;

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

&lt;p&gt;We moved ownership to the people who operate each service and kept lead review for a short list: identity, network boundaries, account creation, and production deletion paths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Corrected: .github/CODEOWNERS
/services/billing-export/  @billing-export-owners
/modules/retry-policy/     @platform-runtime
/modules/iam-boundary/     @platform-leads
/infra/accounts/           @platform-leads
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also added a &lt;code&gt;risk:high&lt;/code&gt; label that requires one lead approval, rather than pretending every YAML file carries the same consequence. We pinned &lt;code&gt;terraform 1.8.5&lt;/code&gt; in &lt;code&gt;.tool-versions&lt;/code&gt; on 2026-08-18, so reviewers were at least looking at plans produced by the same binary. Tool drift had been supplying enough fake disagreement already.&lt;/p&gt;

&lt;p&gt;The new rule only held because our service owners already had production access and knew their own rollback paths. We have not tried it beyond 40 engineers or across a company with three separate compliance teams. We’d expect the ownership map to become its own small administrative animal there.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Praised Firefighting In The Incident Channel
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What we did
&lt;/h3&gt;

&lt;p&gt;During the 03:18 &lt;code&gt;orders-api&lt;/code&gt; incident on 7 August, a senior engineer found that an Envoy sidecar image had been pulled with a stale digest. They manually restarted six pods, traffic recovered, and the incident channel filled with grateful emoji.&lt;/p&gt;

&lt;p&gt;Then we praised the restart in the weekly all-hands.&lt;/p&gt;

&lt;p&gt;We did not praise the engineer for writing down the image-cache condition, because that happened two days later in a quiet document that fewer people read. We effectively taught the group that visible heroics counted more than removing the reason for heroics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it looked reasonable
&lt;/h3&gt;

&lt;p&gt;Incidents are tense, and people deserve credit when they act calmly under pressure. The restart did restore checkout. Nobody wants leaders to respond to a hard night with a lecture about process.&lt;/p&gt;

&lt;p&gt;We also had a habit from smaller teams: the person who fixed the immediate problem was usually the person who could fix the whole problem. That stops being true when the system has 26 services, four cluster node pools, and a deployment controller that behaves differently at 03:00 than it did in staging. Or at least it feels that way after coffee.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it cost us
&lt;/h3&gt;

&lt;p&gt;Within ten days, the same stale-digest condition appeared in &lt;code&gt;catalog-sync&lt;/code&gt;. The engineer on call restarted pods again because it was the known move. We lost 19 minutes of delayed catalogue updates and spent 14 person-hours on two nearly identical incident calls.&lt;/p&gt;

&lt;p&gt;Worse, two people who had noticed the cache behaviour before the first incident did not bring it up in the review. They assumed an emergency workaround was the approved answer.&lt;/p&gt;

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

&lt;p&gt;We changed our incident follow-up language. The incident commander now names three separate contributions: containment, diagnosis, and prevention. They are often done by different people.&lt;/p&gt;

&lt;p&gt;Our postmortem template also includes “what made the fast workaround attractive?” That wording matters. “Why did we not fix it properly?” produces defensive writing and suspiciously clean timelines. The &lt;a href="https://sre.google/sre-book/postmortem-culture/" rel="noopener noreferrer"&gt;Google SRE postmortem guidance&lt;/a&gt; has the right instinct here: get the facts down without making the document a trial.&lt;/p&gt;

&lt;p&gt;For the image issue, we set &lt;code&gt;imagePullPolicy: Always&lt;/code&gt; for the affected deployment class, added digest validation to the release job, and created a runbook that says a restart is containment only. It is deliberately blunt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If your team celebrates the person awake at 03:18, celebrate the person who makes 03:18 boring next month too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  We Assigned Pager Duty By Reporting Line
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What we did
&lt;/h3&gt;

&lt;p&gt;Our escalation policy routed alerts from &lt;code&gt;payments&lt;/code&gt;, &lt;code&gt;fulfilment&lt;/code&gt;, and &lt;code&gt;customer-profile&lt;/code&gt; through the platform manager after the primary responder. The manager had enough context to coordinate work, so the routing felt sensible.&lt;/p&gt;

&lt;p&gt;It also meant a people manager was the automated second-line responder for systems they did not deploy or change.&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;# Bad: escalation policy in Terraform&lt;/span&gt;
&lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;escalation_delay_in_minutes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"user_reference"&lt;/span&gt;
    &lt;span class="nx"&gt;id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pagerduty_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform_manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;h3&gt;
  
  
  Why it looked reasonable
&lt;/h3&gt;

&lt;p&gt;The platform manager attended architecture reviews, knew which teams were stretched, and could wake the right person. On paper, that resembles escalation.&lt;/p&gt;

&lt;p&gt;We confused being informed with being useful at 02:00. Those are different jobs. One of them needs a laptop, current access, and enough familiarity with &lt;code&gt;payment-webhook&lt;/code&gt; to distinguish a bad certificate from a bad deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it cost us
&lt;/h3&gt;

&lt;p&gt;On 12 August at 01:56, a latency alert reached the manager after an on-call engineer was already investigating. The manager had to search the rota, message the service owner, and then relay a dashboard link. The service owner joined 17 minutes later.&lt;/p&gt;

&lt;p&gt;No individual made a bad call. The policy encoded a slow handoff and asked tired people to compensate for it.&lt;/p&gt;

&lt;p&gt;It also damaged trust in the rota. Service owners felt paged by proxy while platform absorbed blame for alerts outside its control.&lt;/p&gt;

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

&lt;p&gt;We route to a service secondary first, then to an incident commander who can coordinate across teams. Managers get an informational notification after 20 minutes, not a wake-up call by default.&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;# Corrected: escalation policy in Terraform&lt;/span&gt;
&lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;escalation_delay_in_minutes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"schedule_reference"&lt;/span&gt;
    &lt;span class="nx"&gt;id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pagerduty_schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payments_secondary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;escalation_delay_in_minutes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"schedule_reference"&lt;/span&gt;
    &lt;span class="nx"&gt;id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pagerduty_schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_commander&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://support.pagerduty.com/main/docs/escalation-policies" rel="noopener noreferrer"&gt;PagerDuty escalation policy documentation&lt;/a&gt; is clear on how the mechanics work. The harder part is deciding who can act, rather than who sits highest on an org chart.&lt;/p&gt;

&lt;p&gt;We still argue about whether managers should ever be in a daytime escalation chain. For customer-data exposure, probably yes. For a failed Kubernetes CronJob, absolutely not.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Treated A Green Dashboard As Good Management
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What we did
&lt;/h3&gt;

&lt;p&gt;For most of July, our leadership update reported cluster availability, deployment completion, and ticket closure. All three were green. Meanwhile, engineers were bypassing the shared delivery path because preview environments took 38 minutes to appear.&lt;/p&gt;

&lt;p&gt;Nobody hid that number. We simply had not made it a number we discussed with any seriousness.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it looked reasonable
&lt;/h3&gt;

&lt;p&gt;Cluster availability was available in Grafana. Deployment completion came out of GitHub Actions. Ticket closure was in Jira, waiting politely for somebody to make a chart. These numbers were consistent and easy to report.&lt;/p&gt;

&lt;p&gt;The preview-environment delay came from a mixture of runner queue time, Terraform state contention, and a slow image scan. It required actual investigation. We chose the numbers that arrived pre-chewed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it cost us
&lt;/h3&gt;

&lt;p&gt;Two teams began keeping long-lived feature branches because they could not get useful feedback in a working day. One team deployed a feature flag directly to production to avoid another preview wait. That flag stayed enabled for five days because nobody was certain which branch had created it.&lt;/p&gt;

&lt;p&gt;We had technically healthy infrastructure supporting increasingly unhealthy delivery habits.&lt;/p&gt;

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

&lt;p&gt;We replaced the status slide with a weekly operating review of four measures: time from merge to production, failed deployment recovery time, on-call pages per service, and age of the oldest unowned alert. We use the definitions from &lt;a href="https://dora.dev/research/" rel="noopener noreferrer"&gt;DORA’s research program&lt;/a&gt; as a starting point, then adjust when the measurement lies.&lt;/p&gt;

&lt;p&gt;For example, “merge to production” excludes changes waiting behind a scheduled customer release. Counting that delay as engineering delivery time would make a calendar meeting look like a platform defect.&lt;/p&gt;

&lt;p&gt;We removed ticket closure entirely. We dislike it as a leadership metric. It rewards chopping work into small rectangles and then admiring the pile.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Called On-Call A Growth Opportunity
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What we did
&lt;/h3&gt;

&lt;p&gt;When a product engineer joined the rota for the first time, we described it as a chance to learn the system. It was a chance to learn the system. It was also a week of interrupted sleep, a 06:40 database connection alert, and a Friday afternoon spent catching up on work they had planned to finish Tuesday.&lt;/p&gt;

&lt;p&gt;We had no written rule for time back after a bad night.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it looked reasonable
&lt;/h3&gt;

&lt;p&gt;Shared on-call builds ownership. We wanted product teams to see the consequences of their release decisions instead of handing every production concern to platform. That preference remains.&lt;/p&gt;

&lt;p&gt;The mistake was treating the learning benefit as payment for the disruption. People do not need a motivational slogan after being woken by an alert caused by a threshold someone else set in 2023.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it cost us
&lt;/h3&gt;

&lt;p&gt;Three engineers swapped out of the rota in the next quarter. One did so quietly, citing “capacity.” We read that as a scheduling issue until their manager told us they had been paged five times in two weeks and had received no protected recovery time.&lt;/p&gt;

&lt;p&gt;Alert quality also got worse because responders deferred cleanup work. Nobody volunteers to improve an alert that might later wake them if the work competes with a sprint commitment.&lt;/p&gt;

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

&lt;p&gt;A page between 23:00 and 06:00 now earns a late start or half-day recovery without negotiation. A second page in the same night triggers a manager check-in and a review of the alert within five working days.&lt;/p&gt;

&lt;p&gt;We also publish the rota alongside expected service work, so a person carrying &lt;code&gt;orders-api&lt;/code&gt; is not assigned a migration cutover that week. This costs delivery capacity. Good. The capacity was fictional before; it was being borrowed from somebody’s sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Replaced Decisions With Status Meetings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What we did
&lt;/h3&gt;

&lt;p&gt;Our Thursday platform meeting had 14 people, 45 minutes, and a standing agenda full of updates. The recurring item was “progress on secrets migration.” For six weeks, everyone reported progress while nobody decided whether service teams had to move by the September deadline.&lt;/p&gt;

&lt;p&gt;The migration repository had 63 open issues. Twelve were blocked on choices that no ticket owner could make.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it looked reasonable
&lt;/h3&gt;

&lt;p&gt;Status meetings make managers feel informed. They also give people a place to raise concerns, which sounds generous until the same concern appears in six consecutive agendas with no owner assigned to resolve it.&lt;/p&gt;

&lt;p&gt;We had been trying to avoid unilateral decisions after a previous migration upset two application teams. Consensus became the safer social move. It was also much slower.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it cost us
&lt;/h3&gt;

&lt;p&gt;By the time we set a policy, the legacy Vault token format had spread to two new services. The team then had to support both formats during the busiest release month of the quarter.&lt;/p&gt;

&lt;p&gt;The meeting itself cost 31 person-hours over those six weeks. The delay cost more, but the meeting cost was easier to calculate and therefore more annoying.&lt;/p&gt;

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

&lt;p&gt;Every recurring operational meeting now has a decision log with three fields: decision required, named decider, and deadline. Updates go in writing before the meeting. If no decision is pending, people get 45 minutes back.&lt;/p&gt;

&lt;p&gt;For the secrets migration, we chose a hard date: new services after 2026-09-15 must use workload identity; existing services get a supported adapter until 2026-12-31. We accepted that two teams would dislike the date more than they disliked ambiguity.&lt;/p&gt;

&lt;p&gt;The adapter is still ugly. We have not found a clean migration path for the vendor service that only accepts a static credential, and nobody is excited about the options.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>leadership</category>
    </item>
    <item>
      <title>SRE Starts With The Alert That Woke Us</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Sat, 22 Aug 2026 05:40:26 +0000</pubDate>
      <link>https://dev.to/devops_oasis/sre-starts-with-the-alert-that-woke-us-1hnk</link>
      <guid>https://dev.to/devops_oasis/sre-starts-with-the-alert-that-woke-us-1hnk</guid>
      <description>&lt;p&gt;&lt;em&gt;How we made service reliability a team job, not an on-call punishment.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;payment-api&lt;/code&gt; alert was still firing when we opened the incident channel, and nobody could tell whether customers were actually blocked or whether a dashboard had found another way to ruin breakfast.&lt;/p&gt;

&lt;p&gt;SRE means &lt;strong&gt;site reliability engineering&lt;/strong&gt;: a way of running software where the people who build services also set clear expectations for how those services should behave in real use. We measure whether they meet those expectations, respond when they do not, and spend planned time removing repeated sources of failure.&lt;/p&gt;

&lt;p&gt;At 08:06, we had a red graph, four Slack replies, and no useful answer to the only question that mattered: could a customer complete a payment? That gap is why we started treating SRE as a working practice rather than a title somebody receives after learning enough Kubernetes nouns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With What A User Can Actually Do
&lt;/h2&gt;

&lt;p&gt;A service can have healthy pods, low CPU use, and a very cheerful deployment status while still failing the one task people came to perform. We learned this with &lt;code&gt;payment-api&lt;/code&gt; in April. Its &lt;code&gt;/healthz&lt;/code&gt; endpoint returned &lt;code&gt;200&lt;/code&gt;, every container was running, and the PostgreSQL connection pool looked normal. Meanwhile, requests to create a payment were waiting 18 seconds on a call to our fraud provider.&lt;/p&gt;

&lt;p&gt;Nobody buying a train ticket cares that our health endpoint is fine.&lt;/p&gt;

&lt;p&gt;For SRE work, we begin with a user action: create an account, load a report, upload a document, complete a payment. That action gives us something concrete to measure and protect. It also stops teams from treating infrastructure metrics as proof that an application works.&lt;/p&gt;

&lt;p&gt;We wrote down three actions for the payments service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A customer can submit a payment.&lt;/li&gt;
&lt;li&gt;A merchant can retrieve its payment status.&lt;/li&gt;
&lt;li&gt;Our nightly settlement worker can send a batch to the bank before 02:00.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are not equally valuable. A delayed merchant status page is annoying. A payment request that disappears or hangs is a financial incident. Settlement missing its bank window has a different kind of unpleasantness, usually involving spreadsheets and a person from finance who has every right to be annoyed.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://sre.google/sre-book/service-level-objectives/" rel="noopener noreferrer"&gt;Google SRE book’s section on service level objectives&lt;/a&gt; is useful here because it separates what users experience from whatever machinery happens to sit behind it. We borrowed the idea, then made the wording much plainer in our own service docs.&lt;/p&gt;

&lt;p&gt;A user action is the unit we protect; the rest is evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure Requests, Not The Mood Of The Cluster
&lt;/h2&gt;

&lt;p&gt;Once we had a useful user action, we needed a number. For payment submission, we count successful HTTP requests and failed HTTP requests. A request is successful if it returns a &lt;code&gt;2xx&lt;/code&gt; response within five seconds. A &lt;code&gt;500&lt;/code&gt;, a timeout, or a response after five seconds counts as bad.&lt;/p&gt;

&lt;p&gt;That five-second line is not sacred. We picked it after looking at 30 days of request timing. Most payments completed in 430–900 ms. At 2.5 seconds, support tickets started to correlate with retries. At five seconds, our web client gave up and showed the customer an error. We chose the point where the customer’s experience had already become poor, rather than pretending 400 ms was a promise we could keep during the Monday morning rush.&lt;/p&gt;

&lt;p&gt;In SRE terms, the thing measured is a service level indicator, usually shortened to SLI. We call it “the payment success rate” in conversation because acronyms spread quickly enough without our help.&lt;/p&gt;

&lt;p&gt;This Prometheus query is close to the one on our dashboard:&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{service="payment-api",code=~"2.."}[5m]))
/
sum(rate(http_requests_total{service="payment-api"}[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query is deliberately boring. It counts good requests divided by all requests. It does not estimate customer happiness from node load or count pods with names containing &lt;code&gt;payment&lt;/code&gt;. Those metrics still have a place when diagnosing a fault, but they are poor promises to make to users.&lt;/p&gt;

&lt;p&gt;We also exclude requests made by our synthetic checker from this ratio. Otherwise a healthy checker can keep the graph looking better while ordinary traffic is failing, which is exactly the sort of technical correctness we dislike.&lt;/p&gt;

&lt;p&gt;A useful SLI maps a visible user action to a number we can inspect during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set A Target You Can Defend In A Meeting
&lt;/h2&gt;

&lt;p&gt;A service level objective, or SLO, is the target for that measurement over a period of time. Ours for payment submission is 99.9% successful requests in a rolling 28-day window.&lt;/p&gt;

&lt;p&gt;That means we permit roughly 43 minutes of failed or slow request time per month if every request fails at once. Real faults are messier than that: a partial outage may consume the allowance across thousands of requests in ten minutes. Still, the number gives us a shared boundary.&lt;/p&gt;

&lt;p&gt;We tried 99.99% first.&lt;/p&gt;

&lt;p&gt;It did not work.&lt;/p&gt;

&lt;p&gt;The target sounded suitably serious in the planning document, but it gave &lt;code&gt;payment-api&lt;/code&gt; about four minutes and 19 seconds of failure allowance in 30 days. A routine database failover consumed most of that. A deploy that caused a two-minute spike put us in a state where the sensible response was “stop changing anything,” including a fix for the thing that had just failed. We changed it after two months. Nobody has suggested going back.&lt;/p&gt;

&lt;p&gt;An SLO should reflect the cost of failure and the cost of making change. For payment submission, 99.9% is defensible because a short failure is costly, but we also deploy fixes and dependency changes every week. For our internal analytics export, 99.5% is plenty. The report is useful at 09:00; it does not need a ceremonial response at 03:00 because one CSV was 12 minutes late.&lt;/p&gt;

&lt;p&gt;The target is a negotiated engineering decision, not a number copied from a cloud provider’s brochure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat The Error Budget As Permission To Change
&lt;/h2&gt;

&lt;p&gt;The difference between the target and actual performance is called an error budget. If our target is 99.9%, the remaining 0.1% is the amount of bad service we have agreed can happen in the measurement window.&lt;/p&gt;

&lt;p&gt;That phrasing can sound grim. We prefer to see it as permission to take normal engineering risks. Deployments, schema changes, cache migrations, and dependency upgrades all carry some chance of disruption. If we demand zero disruption forever, we get slower changes, stale dependencies, and people quietly avoiding work that needs doing.&lt;/p&gt;

&lt;p&gt;At 11:35, during the payment incident, the budget had fallen from 71% remaining to 63%. That gave us room to ship a timeout change and route a small percentage of traffic around the fraud provider. Had we only had 2% left, we would have made a different call: freeze feature releases, use the safer rollback path, and put the reliability work at the top of the sprint.&lt;/p&gt;

&lt;p&gt;We keep the policy 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;payment-api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;slo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;99.9&lt;/span&gt;
  &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;28d&lt;/span&gt;
  &lt;span class="na"&gt;budget_remaining_below&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10%&lt;/span&gt;
  &lt;span class="na"&gt;release_action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approval&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on-call&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;service&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;owner"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not an automatic deployment lock. We tried a hard lock in GitHub Actions last year, and engineers worked around it by classifying urgent changes as operational patches. The YAML survived; the lock did not.&lt;/p&gt;

&lt;p&gt;An error budget gives release decisions a number to argue from instead of relying on whichever person is most worried in the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Page People Only When They Can Act
&lt;/h2&gt;

&lt;p&gt;An alert should tell an awake person that they need to do something now. Not investigate later. Not admire a graph. Not acknowledge it so that the alert noise reduces by one.&lt;/p&gt;

&lt;p&gt;Our old alert set had 146 rules for a cluster of 24 nodes. At 02:40 one Thursday, the on-call engineer received 17 pages for a single Redis failure: node disk pressure, pod restarts, connection errors, cache miss rate, eviction count, and several alerts that had apparently been written by a committee of anxious refrigerators.&lt;/p&gt;

&lt;p&gt;We cut that down. The page now fires when the payment SLO is burning quickly enough that we are likely to consume 5% of the monthly budget in one hour. The disk and restart alerts still exist, but they go to a working-hours channel unless they are directly causing user failure.&lt;/p&gt;

&lt;p&gt;This uses the “burn rate” approach described in the &lt;a href="https://sre.google/workbook/alerting-on-slos/" rel="noopener noreferrer"&gt;SRE workbook’s alerting chapter&lt;/a&gt;. We like it because it joins urgency to impact. A five-minute blip at 03:00 can wait. A failure eating a month’s allowance before lunch cannot.&lt;/p&gt;

&lt;p&gt;The alert notification includes three links: the SLO graph, the relevant deployment history, and the runbook. It does not contain fourteen labels, a container hash, and the hostname of a node that may no longer exist by the time somebody reads it.&lt;/p&gt;

&lt;p&gt;The runbook is where we put immediate checks, known failure modes, and safe rollback steps. It is not a wiki page titled “Payments Operational Knowledge” last edited in 2022 by someone who moved teams.&lt;/p&gt;

&lt;p&gt;A page earns its place on the rota only when an engineer has a real action available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability Work Has To Compete For Sprint Time
&lt;/h2&gt;

&lt;p&gt;SRE fails when reliability tasks become “we’ll get to it after the roadmap.” There is always another roadmap item. There is also always another slow query, expiring certificate, or queue consumer that only fails after a long weekend.&lt;/p&gt;

&lt;p&gt;We reserve capacity in each service team’s sprint for reliability work. Not a fixed 20% in every case; fixed percentages create odd incentives and fake tickets. Instead, the service owner brings the largest repeat incident, highest-risk dependency, or most worrying budget trend to planning. We ask what would make that problem less likely next month.&lt;/p&gt;

&lt;p&gt;For payments, the answer this quarter was not another dashboard. We had plenty of dashboards. We added a circuit breaker around the fraud provider, recorded dependency timing separately, and changed the retry policy so that 400 failed requests do not all retry in the same second.&lt;/p&gt;

&lt;p&gt;Our colleague Mina still thinks circuit breakers are a polite name for “giving up early.” She is partly right. We are still arguing about whether failed fraud checks should enter a manual review queue during a provider outage or be rejected immediately. That decision crosses risk, support staffing, and customer trust; no latency graph will settle it for us.&lt;/p&gt;

&lt;p&gt;We track reliability work in the same backlog as product work. If it needs a separate spreadsheet to exist, it is already losing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Words We Use Before They Become Meeting Noise
&lt;/h2&gt;

&lt;p&gt;A short glossary helps because SRE has enough abbreviations to make ordinary incident calls sound like a radio test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service level indicator (SLI):&lt;/strong&gt; The measurement of a user-facing action. For us, payment requests completed successfully within five seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service level objective (SLO):&lt;/strong&gt; The target for that measurement over a time window. Our payment submission target is 99.9% over 28 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error budget:&lt;/strong&gt; The allowed amount of failure implied by the SLO. It is what remains when actual performance is compared with the target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Burn rate:&lt;/strong&gt; How quickly the service is spending its error budget. A fast burn rate deserves attention even if the monthly number still looks acceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-call:&lt;/strong&gt; The person scheduled to respond when an alert pages. This should rotate, include proper handover, and not quietly become one person’s permanent second job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runbook:&lt;/strong&gt; A practical page for responding to a known alert: where to look, what is safe to change, and when to escalate.&lt;/p&gt;

&lt;p&gt;Prometheus has a clear &lt;a href="https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/" rel="noopener noreferrer"&gt;alerting rules reference&lt;/a&gt; if you are building alerts from scratch, while &lt;a href="https://prometheus.io/docs/alerting/latest/alertmanager/" rel="noopener noreferrer"&gt;Alertmanager’s grouping documentation&lt;/a&gt; explains how to avoid receiving seventeen versions of the same bad news.&lt;/p&gt;

&lt;p&gt;Words are useful only if the person holding the pager can turn them into a decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep The First Version Small Enough To Maintain
&lt;/h2&gt;

&lt;p&gt;We have not tried this approach past 40 nodes or across dozens of independently deployed teams. At our size, one platform group and six service teams can still agree on common labels, a dashboard layout, and who owns an alert. Larger organisations will need more structure, and probably more patience than we had at 08:06.&lt;/p&gt;

&lt;p&gt;Start with one service that matters, one user action, one measured target, and one page that somebody can act on. Do not wait for a grand reliability programme. We began with payment submission because its failures were visible, expensive, and already waking people up.&lt;/p&gt;

&lt;p&gt;By 16:40, the fraud provider had recovered, the error budget had stopped falling, and the incident channel had gone quiet. We still have a &lt;code&gt;node_filesystem_avail_bytes&lt;/code&gt; alert that wakes people too often on a particular worker pool. It has been open for 19 days, which is annoyingly longer than the incident that made us write this post.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>sre</category>
    </item>
    <item>
      <title>Ansible Myths We Met During The Debian 12 Cutover</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Sat, 22 Aug 2026 05:40:06 +0000</pubDate>
      <link>https://dev.to/devops_oasis/ansible-myths-we-met-during-the-debian-12-cutover-3g2j</link>
      <guid>https://dev.to/devops_oasis/ansible-myths-we-met-during-the-debian-12-cutover-3g2j</guid>
      <description>&lt;p&gt;&lt;em&gt;What held up across 28 hosts, and what absolutely did not.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;02:13.&lt;/p&gt;

&lt;p&gt;That was when the last Debian 11 VM in our payments group stopped accepting the old OpenSSH cipher list, halfway through an Ansible run that had looked boring for 41 minutes. Boring is usually a compliment in infrastructure work. At 02:13, it became a theory.&lt;/p&gt;

&lt;p&gt;We moved 28 application and utility hosts to Debian 12 over three evenings: 14 API nodes, six Celery workers, four reporting boxes, two Redis replicas, and two machines nobody could name without checking NetBox. We ran &lt;code&gt;ansible-core&lt;/code&gt; 2.17.7 from a pinned Python 3.12 virtual environment on our control host. The exercise produced a familiar set of opinions from people who have used Ansible once, inherited it reluctantly, or seen a YAML file commit a minor crime.&lt;/p&gt;

&lt;p&gt;Most of those opinions contain a grain of truth. Grains are how people end up eating sand.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Ansible Is Just SSH With YAML”
&lt;/h2&gt;

&lt;p&gt;Verdict: false, though SSH is still where many bad evenings begin.&lt;/p&gt;

&lt;p&gt;For ordinary Linux hosts, Ansible’s default transport is SSH, and that fact encourages people to dismiss the rest as a collection of remote shell commands with more indentation. We did use SSH. We also used inventory groups, facts, idempotent modules, handlers, privilege escalation rules, check mode, variable precedence, and a run history we could inspect after someone asked why &lt;code&gt;cron&lt;/code&gt; had restarted on a reporting node at 01:47.&lt;/p&gt;

&lt;p&gt;Calling all of that “just SSH” is like calling PostgreSQL “just files on disk.” Technically defensible, professionally unhelpful.&lt;/p&gt;

&lt;p&gt;The distinction mattered during the cutover because our hosts were not alike. The API nodes run &lt;code&gt;gunicorn&lt;/code&gt; behind Nginx. Celery workers have different systemd limits. The reporting boxes mount an NFS share that the application hosts never see. A shell loop would have needed its own logic for each difference, along with error handling and a way to tell whether a retry had changed anything. We have written those loops before. They age like yoghurt in a server room.&lt;/p&gt;

&lt;p&gt;Here is the small part of the play that put the right APT source on each Debian 12 host and notified Nginx only when its file changed:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure internal package source&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.deb822_repository&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;oasis-internal&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;deb&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;uris&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://apt.devopsoasis.internal/debian"&lt;/span&gt;
    &lt;span class="na"&gt;suites&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="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ansible_distribution_release&lt;/span&gt;&lt;span class="nv"&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;components&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;signed_by&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://apt.devopsoasis.internal/keys/archive.gpg"&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reload nginx&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;Install application packages&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.apt&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;oasis-api={{ api_package_version }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
    &lt;span class="na"&gt;update_cache&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;handlers&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;Reload nginx&lt;/span&gt;
    &lt;span class="na"&gt;ansible.builtin.service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
      &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reloaded&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Debian repository module and the APT module gave us useful state instead of an optimistic &lt;code&gt;curl | tee&lt;/code&gt; sequence. We could rerun the play after fixing the cipher issue and see 23 hosts report &lt;code&gt;ok&lt;/code&gt;, four make the expected package changes, and one fail on a stale internal repository key.&lt;/p&gt;

&lt;p&gt;That said, Ansible does not remove SSH’s sharp edges. A stale &lt;code&gt;known_hosts&lt;/code&gt; entry, a host key rotation, an expired jump-host certificate, or a control socket left under &lt;code&gt;/tmp&lt;/code&gt; can still stop a run before a task begins. We had all four categories represented in the preceding month, because apparently our infrastructure enjoys variety.&lt;/p&gt;

&lt;p&gt;Ansible gives SSH a memory and a grammar. It does not grant SSH better manners.&lt;/p&gt;

&lt;h2&gt;
  
  
  “If A Playbook Is Idempotent, Rerunning It Is Safe”
&lt;/h2&gt;

&lt;p&gt;Verdict: dangerously false.&lt;/p&gt;

&lt;p&gt;Idempotence means a task should reach the same desired state when applied repeatedly. It does not mean every action surrounding that task is harmless. A package install can be idempotent while the package’s post-install script restarts a service. A template can be idempotent while its handler drains a connection pool at a bad moment. A database migration can be “already applied” and still leave a node with an incompatible binary if deployment order goes wrong.&lt;/p&gt;

&lt;p&gt;We had a clean example on the Celery workers. The role installed &lt;code&gt;oasis-worker&lt;/code&gt;, rendered a systemd unit, and restarted the service when either changed. On Debian 11, the worker used a unit file with &lt;code&gt;Restart=always&lt;/code&gt;. On Debian 12, we changed it to &lt;code&gt;Restart=on-failure&lt;/code&gt; after discovering that a bad RabbitMQ credential caused a very energetic restart loop and 9 GB of logs over an on-call shift.&lt;/p&gt;

&lt;p&gt;The first run changed the unit file. Good. The second run should have reported no changes. Instead, it restarted all six workers because a task used &lt;code&gt;state: restarted&lt;/code&gt; as a convenient substitute for thinking.&lt;/p&gt;

&lt;p&gt;We replaced it with this:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install worker systemd unit&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oasis-worker.service.j2&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/systemd/system/oasis-worker.service&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0644"&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Reload systemd&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Restart oasis worker&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;Ensure worker is enabled and running&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.systemd_service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oasis-worker&lt;/span&gt;
    &lt;span class="na"&gt;enabled&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;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;started&lt;/span&gt;

&lt;span class="na"&gt;handlers&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;Reload systemd&lt;/span&gt;
    &lt;span class="na"&gt;ansible.builtin.systemd_service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;daemon_reload&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;Restart oasis worker&lt;/span&gt;
    &lt;span class="na"&gt;ansible.builtin.systemd_service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oasis-worker&lt;/span&gt;
      &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;restarted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler behaviour documented by Ansible is useful here: notifications coalesce, so six changed tasks still cause one restart at the end of a play. That only helps if we notify on real changes and avoid casually restarting a process because the playbook happened to visit it.&lt;/p&gt;

&lt;p&gt;There is a second trap: &lt;code&gt;changed_when: false&lt;/code&gt;. People add it to quiet noisy shell tasks, then forget that they have hidden the only signal a downstream handler had. We found one role doing exactly that around &lt;code&gt;update-ca-certificates&lt;/code&gt;. It was marked unchanged even when it had imported a new internal CA. The application unit therefore kept running with an old trust store until its next scheduled restart.&lt;/p&gt;

&lt;p&gt;Silence in Ansible output is not proof that nothing happened.&lt;/p&gt;

&lt;p&gt;We now treat reruns differently by class of work. Package and file convergence can run repeatedly. Service reloads need a reason. Schema changes get a separate deployment step and a named human watching the output. We still have an argument going about whether certificate rotation belongs in the same play as application rollout. Marta, who carries the Thursday primary rota, wants it split permanently. We have not settled it, mostly because nobody enjoys owning the extra release button.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Roles Make Every Ansible Repository Easier To Maintain”
&lt;/h2&gt;

&lt;p&gt;Verdict: false after the third layer of includes.&lt;/p&gt;

&lt;p&gt;Roles are useful. We keep roles for things with a real lifecycle: &lt;code&gt;oasis_nginx&lt;/code&gt;, &lt;code&gt;oasis_api&lt;/code&gt;, &lt;code&gt;oasis_worker&lt;/code&gt;, &lt;code&gt;node_exporter&lt;/code&gt;, and &lt;code&gt;vector_agent&lt;/code&gt;. Each has defaults, templates, handlers, and tests. That structure lets a new engineer find the file that owns &lt;code&gt;/etc/nginx/sites-enabled/oasis-api.conf&lt;/code&gt; without hiring a tracking dog.&lt;/p&gt;

&lt;p&gt;Then roles become a way to hide choices.&lt;/p&gt;

&lt;p&gt;Our oldest repository had a &lt;code&gt;common&lt;/code&gt; role that installed base packages, set SSH settings, configured journald, added users, installed monitoring, patched APT sources, mounted volumes, and once, for reasons lost to history, configured &lt;code&gt;mailutils&lt;/code&gt;. Every host received it. Its defaults file had 67 variables. The role’s README said “foundation configuration,” which meant nobody knew whether changing it would restart half the fleet.&lt;/p&gt;

&lt;p&gt;We removed &lt;code&gt;common&lt;/code&gt; during the Debian 12 work. Not renamed it. Removed it.&lt;/p&gt;

&lt;p&gt;The replacement is less elegant to people who enjoy directory trees. The base play now names what it does:&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;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;base_packages&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ssh_hardening&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;journald&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_exporter&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vector_agent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is more repetitive, and we prefer it. A deployment plan should make its dependencies visible. When &lt;code&gt;ssh_hardening&lt;/code&gt; changes, we can run it against the staging bastion first rather than wonder what else &lt;code&gt;common&lt;/code&gt; believes it owns this week.&lt;/p&gt;

&lt;p&gt;We also stopped putting application-specific variables in &lt;code&gt;group_vars/all.yml&lt;/code&gt;. That file had become the cupboard under the stairs: Redis memory limits beside deploy keys, Nginx rate settings beside a legacy hostname, each one apparently too small to deserve a proper home. Variable precedence in Ansible is powerful enough to support almost any arrangement, which is a polite way of saying it will let us build a difficult mess. The precedence rules are clear; the harder part is declining clever arrangements before they spread.&lt;/p&gt;

&lt;p&gt;Roles help when they represent a bounded unit of ownership. A role that changes SSH, packages, users, metrics, and mail is a landfill with a &lt;code&gt;defaults/main.yml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Check Mode Tells Us What Production Will Do”
&lt;/h2&gt;

&lt;p&gt;Verdict: partly true, and we still run it on every merge.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ansible-playbook --check --diff&lt;/code&gt; catches a surprising amount. It spots malformed templates, missing variables, unexpected file diffs, and package changes that would otherwise appear during a production window. For the Debian 12 migration, check mode caught an Nginx template referring to &lt;code&gt;ssl_protocols TLSv1.2 TLSv1.3;&lt;/code&gt; on a host group where TLS terminated at the load balancer. The task was valid. The configuration was unnecessary. That distinction saved us a small amount of clutter, which is the usual reward for doing review properly.&lt;/p&gt;

&lt;p&gt;It cannot predict everything.&lt;/p&gt;

&lt;p&gt;Modules vary in their check-mode support. Commands and scripts cannot know their impact unless we tell Ansible with &lt;code&gt;creates&lt;/code&gt;, &lt;code&gt;removes&lt;/code&gt;, or explicit &lt;code&gt;changed_when&lt;/code&gt; logic. External systems are worse. Our deployment task calls an internal release API to register the package version. In check mode, we skip it. Running a fake registration against production would turn a dry run into an incident rehearsal.&lt;/p&gt;

&lt;p&gt;Package managers also have opinions. APT can calculate planned installs, but the final answer depends on repository contents, dependency resolution, held packages, and whether the mirror has finished syncing. On the second cutover night, our internal mirror had &lt;code&gt;libpq5&lt;/code&gt; 15.8 for eleven minutes before its metadata caught up. Check mode at 22:06 looked fine; the real run at 22:17 installed nothing until we forced an index refresh.&lt;/p&gt;

&lt;p&gt;This only held because our package mirror is local and the sync lag is usually under 15 minutes. We have not tried this process across 40 nodes or across regions with separate mirrors. The current serial batch size of two works because someone can read the output before the next pair begins, not because two is a mystical number.&lt;/p&gt;

&lt;p&gt;Check mode is a review aid. Treating it as a production simulation invites a very confident failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Ansible Is Too Slow For Real Deployments”
&lt;/h2&gt;

&lt;p&gt;Verdict: mostly true, if “real deployments” means every host deserves parallel attention.&lt;/p&gt;

&lt;p&gt;Ansible is not slow in the way people mean when they complain about it. A play spends time connecting, gathering facts, copying files, waiting for package locks, and asking remote services whether they already match the requested state. That work is visible. A bash fan-out script hides the same waiting behind interleaved output and gives it a name like &lt;code&gt;deploy-final-v4.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For 28 hosts, our full Debian 12 convergence play took 18 minutes and 36 seconds with &lt;code&gt;forks = 10&lt;/code&gt;. Fact gathering alone consumed just under four minutes on the first run. We could turn it off, and we did for the application-only deployment play. We did not turn it off for the OS migration because several templates depend on distribution release and interface facts, and discovering that late is tedious.&lt;/p&gt;

&lt;p&gt;The larger problem is that Ansible’s default execution model makes safe rolling work feel deliberate. We set &lt;code&gt;serial: 2&lt;/code&gt; on API nodes, wait for each host to return to the load balancer, and only then move on. That is slower than replacing all 14 at once. We prefer slower. The incident report from 2023, when a bad Nginx include removed health-check responses across an entire pool, remains available to anyone tempted by speed.&lt;/p&gt;

&lt;p&gt;We do use the free strategy for low-risk work such as updating node exporter and Vector. It prevents a slow reporting host from making every other machine wait at the same task boundary. We do not use it for application releases, where matching order across a small batch is easier to observe and roll back.&lt;/p&gt;

&lt;p&gt;Ansible has a ceiling. We would not choose it to coordinate thousands of ephemeral containers, and we dislike pretending a tool should solve jobs it was not built for. For a few dozen long-lived servers with awkward local differences, the minutes it spends checking state are cheaper than the minutes we spend explaining a broad outage.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ansible</category>
    </item>
    <item>
      <title>Jenkins Versus GitHub Actions After Our 43-Minute Queue</title>
      <dc:creator>DevOps Oasis</dc:creator>
      <pubDate>Sat, 22 Aug 2026 05:39:45 +0000</pubDate>
      <link>https://dev.to/devops_oasis/jenkins-versus-github-actions-after-our-43-minute-queue-3p48</link>
      <guid>https://dev.to/devops_oasis/jenkins-versus-github-actions-after-our-43-minute-queue-3p48</guid>
      <description>&lt;p&gt;&lt;em&gt;What we kept, what we moved, and why Jenkins still runs one awkward job.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At 10:17 on Tuesday, our release branch had 19 builds waiting behind a Jenkins controller that was technically healthy and practically useless. The oldest job had been queued for 43 minutes. Two engineers were watching the same green progress bar on Slack. One of them suggested “just adding executors,” which is how Jenkins makes sure we remember it has a sense of humour.&lt;/p&gt;

&lt;p&gt;We compared three paths for the services we build and deploy from GitHub: keep Jenkins on Kubernetes, move normal CI work to GitHub Actions, or run GitLab CI for the teams already using GitLab’s package and security features. This wasn’t a feature checklist exercise. We had 34 repositories, roughly 280 builds on a busy weekday, Java and Node services, Terraform plans, and one antique deployment job that talks to a vendor appliance through a jump host.&lt;/p&gt;

&lt;p&gt;Our plain answer: GitHub Actions won for most application CI. Jenkins stayed for the strange jobs with network access, custom hardware, or years of scripted behaviour nobody wants to translate on a Thursday afternoon. GitLab CI is a decent choice if GitLab already owns your source control. We would not introduce it beside GitHub merely to replace Jenkins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Queue Problem Is Really a Worker Problem
&lt;/h2&gt;

&lt;p&gt;Our Jenkins controller ran as a StatefulSet in EKS, with ephemeral Kubernetes agents created through the &lt;a href="https://plugins.jenkins.io/kubernetes/" rel="noopener noreferrer"&gt;Jenkins Kubernetes plugin&lt;/a&gt;. On paper, this gave us elastic executors. In practice, the agent pod templates had grown into a 480-line shared library, node images were pulled too often, and a Docker-in-Docker sidecar occasionally sat in &lt;code&gt;ContainerCreating&lt;/code&gt; while its sibling job waited.&lt;/p&gt;

&lt;p&gt;The controller itself wasn’t the bottleneck. The queue was.&lt;/p&gt;

&lt;p&gt;We had capped the &lt;code&gt;ci-build&lt;/code&gt; node group at 12 &lt;code&gt;m6i.large&lt;/code&gt; instances because it already cost enough during normal hours. Each Maven build requested 2 CPU and 4 GiB RAM. A few test suites asked for 6 GiB, Kubernetes packed them poorly, and cluster autoscaler took four to seven minutes to add nodes. By the time capacity appeared, another branch push had arrived.&lt;/p&gt;

&lt;p&gt;GitHub Actions moved that waiting time somewhere else. Hosted runners started most Node and unit-test jobs in under a minute. The Windows jobs were less charming, but we only have four of those. GitLab’s shared runners behave similarly, though we have seen queue time vary more with GitLab.com’s shared capacity than we like for release work.&lt;/p&gt;

&lt;p&gt;Here’s the blunt trade-off:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Jenkins on Kubernetes&lt;/th&gt;
&lt;th&gt;GitHub Actions&lt;/th&gt;
&lt;th&gt;GitLab CI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Normal app build startup&lt;/td&gt;
&lt;td&gt;2–8 minutes in our cluster&lt;/td&gt;
&lt;td&gt;15–60 seconds&lt;/td&gt;
&lt;td&gt;30–120 seconds on shared runners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom network access&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Requires self-hosted runner&lt;/td&gt;
&lt;td&gt;Requires self-managed runner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipeline code&lt;/td&gt;
&lt;td&gt;Powerful, often overgrown Groovy&lt;/td&gt;
&lt;td&gt;YAML plus actions&lt;/td&gt;
&lt;td&gt;YAML, generally cleaner than Jenkinsfiles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin and action risk&lt;/td&gt;
&lt;td&gt;Plugin upgrades can break controllers&lt;/td&gt;
&lt;td&gt;Third-party actions need pinning&lt;/td&gt;
&lt;td&gt;Templates and includes need review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost shape&lt;/td&gt;
&lt;td&gt;EKS nodes, storage, admin time&lt;/td&gt;
&lt;td&gt;Per-minute after included use&lt;/td&gt;
&lt;td&gt;Per-minute or runner infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging odd failures&lt;/td&gt;
&lt;td&gt;Logs spread across controller, pod, cluster&lt;/td&gt;
&lt;td&gt;Usually one run page&lt;/td&gt;
&lt;td&gt;Usually one pipeline page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Our recommendation&lt;/td&gt;
&lt;td&gt;Keep for exceptional jobs&lt;/td&gt;
&lt;td&gt;Default for GitHub-hosted teams&lt;/td&gt;
&lt;td&gt;Default only for GitLab-hosted teams&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The surprise was that cost got worse before it got better. In the first month after moving 22 repositories, our GitHub Actions bill rose from about $180 in trial usage to $1,460. We had copied Jenkins behaviour too literally: every pull request ran integration tests, image builds, dependency scans, and a 19-minute Playwright suite.&lt;/p&gt;

&lt;p&gt;Then we split checks by path, cancelled superseded runs, and put browser tests behind a merge-queue label. The next month landed at $690. Jenkins node spend also dropped by roughly $510, though our finance report made that connection about as obvious as a failed Helm rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same Maven Build In Three Pipeline Files
&lt;/h2&gt;

&lt;p&gt;We used the same service for the comparison: &lt;code&gt;billing-api&lt;/code&gt;, a Spring Boot service with Maven tests, a container image, and a Helm deployment to staging. It has enough moving parts to expose weak spots, but it isn’t one of the cursed repositories.&lt;/p&gt;

&lt;p&gt;Jenkins still gives us the most freedom. It also gives every team enough rope to build a second deployment system inside a shared library.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;kubernetes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;yaml&lt;/span&gt; &lt;span class="s2"&gt;"""
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: maven
    image: maven:3.9.9-eclipse-temurin-21
    command: ['cat']
    tty: true
"""&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;stages&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Test'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'maven'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
          &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'mvn -B -DskipITs verify'&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="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Image'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="s1"&gt;'main'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
      &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./ci/build-and-push.sh'&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GitHub Actions version is shorter, and more of its behaviour is visible to a developer who has never had Jenkins administrator access. We pin third-party actions to commit SHAs in the real files; abbreviated tags below are easier to read but not what we permit in protected branches. GitHub’s own security hardening guidance is sensible here, particularly on action pinning and token permissions.&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;billing-api&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;pull_request&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;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&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-24.04&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-java@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;distribution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;temurin&lt;/span&gt;
          &lt;span class="na"&gt;java-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;21"&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mvn -B -DskipITs verify&lt;/span&gt;

  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main'&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-24.04&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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./ci/build-and-push.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitLab CI is the least surprising YAML of the three for a conventional pipeline. Its &lt;code&gt;rules&lt;/code&gt; syntax is better than the conditional knots we’ve seen in Jenkinsfile Groovy. GitLab’s &lt;a href="https://docs.gitlab.com/runner/executors/" rel="noopener noreferrer"&gt;runner executor documentation&lt;/a&gt; is worth reading before committing to shared versus Kubernetes runners; the operational split matters more than the syntax.&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;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;image&lt;/span&gt;

&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&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;maven:3.9.9-eclipse-temurin-21&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.m2/repository&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mvn -B -DskipITs verify&lt;/span&gt;

&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image&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;docker:27&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker:27-dind&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;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$CI_COMMIT_BRANCH&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;$CI_DEFAULT_BRANCH'&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./ci/build-and-push.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a plain service, we’d choose Actions. Jenkins’ extra power only pays for itself when the job really needs it, not when someone wants an &lt;code&gt;input&lt;/code&gt; step and a decorative Build With Parameters page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credentials Are Where Jenkins Shows Its Age
&lt;/h2&gt;

&lt;p&gt;We spent more time on credentials than migration scripts.&lt;/p&gt;

&lt;p&gt;Jenkins had 67 credentials in its store when we started. Twelve were still in use. Seven had names like &lt;code&gt;prod-token-final2&lt;/code&gt;, which conveyed neither ownership nor comfort. A folder-scoped credential can be perfectly reasonable, but a Jenkins administrator can still reach a great deal of it, and plugin-specific credential bindings make audits tedious.&lt;/p&gt;

&lt;p&gt;For GitHub Actions, we switched AWS deployments to OIDC and removed long-lived access keys from the pipeline path. The trust policy limits the role to our &lt;code&gt;platform/billing-api&lt;/code&gt; repository and the &lt;code&gt;main&lt;/code&gt; branch environment.&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;"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="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:acme-platform/billing-api:environment:production"&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;That was one of the few changes that felt immediately cleaner. The deploy job requests a short-lived token, AWS records the role session, and we stop pretending a secret rotated every 90 days is a happy secret. GitHub documents the &lt;a href="https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws" rel="noopener noreferrer"&gt;OIDC flow for AWS&lt;/a&gt; well enough that we didn’t have to invent a wrapper.&lt;/p&gt;

&lt;p&gt;GitLab supports the same general pattern with ID tokens. If your repositories live there, use them. We would avoid storing cloud keys in either CI product unless the target system has no federation option.&lt;/p&gt;

&lt;p&gt;Jenkins can do OIDC too, via scripts, plugins, or an external secrets operator. We have it working for two jobs. The issue is consistency: every Jenkins library can choose its own route, and then the security review turns into archaeology.&lt;/p&gt;

&lt;p&gt;We left one Jenkins credential in place: a client certificate required by that vendor appliance. Its API only accepts mutual TLS from a fixed private address, and its certificate rotation procedure arrives as a PDF once a year. Some systems do make their own case for retirement.&lt;/p&gt;

&lt;p&gt;Could we move that appliance job to a self-hosted GitHub runner and shut Jenkins off entirely?&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-Hosted Runners Recreate Part of the Jenkins Problem
&lt;/h2&gt;

&lt;p&gt;Yes, we could. We tested it.&lt;/p&gt;

&lt;p&gt;A self-hosted Actions runner in the private subnet could reach the appliance, mount the certificate from Secrets Manager, and execute the deployment script. The workflow itself was fine. The trouble began when we considered who would patch, drain, scale, and investigate those runners at 02:00.&lt;/p&gt;

&lt;p&gt;Jenkins already had that machinery, imperfectly. Our agents were Kubernetes pods, isolated per build, and constrained with service accounts and network policies. A self-hosted runner can be ephemeral too, and GitHub recommends ephemeral runners for security-sensitive work. But we would still need an autoscaler, images, AMI or container patching, labels, runner registration, and enough logging to answer “what ran on this host?”&lt;/p&gt;

&lt;p&gt;That’s Jenkins-shaped work wearing a GitHub badge.&lt;/p&gt;

&lt;p&gt;We tried Actions Runner Controller for a week on a non-production cluster. It worked, but it added another controller, runner scale sets, GitHub App credentials, and operational ownership. Our platform team has five people, one of whom is on parental leave until October. We don’t have spare enthusiasm for another control plane.&lt;/p&gt;

&lt;p&gt;This only held because the appliance job runs two or three times a week. If it ran 200 times a day, we would build the runner pool and accept the maintenance. At that volume, manual exceptions become their own incident category.&lt;/p&gt;

&lt;p&gt;For now, Jenkins owns the private-network and hardware-adjacent jobs: appliance deploys, Android signing, and a load-test suite that pushes 25 Gbit/s through a lab VLAN. There are nine of them. Everything else is moving out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failed Builds Need Fewer Places To Look
&lt;/h2&gt;

&lt;p&gt;Jenkins failures trained our team to open three tabs: build log, Kubernetes events, and Grafana. Four tabs if the shared library had changed. A red Jenkins job might mean a test failed, an agent could not schedule, the container registry throttled us, or the controller had lost a WebSocket connection to an agent.&lt;/p&gt;

&lt;p&gt;GitHub Actions collapses most normal failures into one place. Logs are still noisy, especially when an action emits coloured furniture instead of useful output, but the run page has the checkout, cache, test, artifact, and deployment evidence together. GitLab offers a similar experience.&lt;/p&gt;

&lt;p&gt;There’s a cost to that convenience. Hosted runners hide the host. When a GitHub &lt;code&gt;ubuntu-24.04&lt;/code&gt; image changed and our Playwright browser install broke, we had less ability to inspect the runner than we had with our own pods. Pinning the browser package fixed it, but the first failure message was just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: browserType.launch: Executable doesn't exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our Jenkins setup had a different failure mode: the browser image was pinned for so long that it contained a six-month-old Chromium build. Predictability can quietly turn into neglect.&lt;/p&gt;

&lt;p&gt;We also found that GitHub’s concurrency control stopped a lot of wasted work:&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;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pr-${{ github.event.pull_request.number }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A developer pushing five commits in ten minutes no longer buys five copies of the same integration run. Jenkins can do this with milestone steps, abort plugins, or custom Groovy. We had all three patterns across repositories, naturally.&lt;/p&gt;

&lt;p&gt;Marta, our Tuesday primary, preferred Jenkins logs because “at least the disaster is familiar.” She was right for the first fortnight. By week three, she stopped asking which agent pod had vanished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugin Freedom Has a Maintenance Bill
&lt;/h2&gt;

&lt;p&gt;Our Jenkins controller had 46 installed plugins. We removed 11 before the migration and still found dependencies on workflow APIs nobody had consciously selected. A plugin update is rarely exciting until it breaks a shared library and 34 repositories lose the ability to publish test reports.&lt;/p&gt;

&lt;p&gt;We held Jenkins core at 2.452.3 for longer than we should have because an upgrade changed behaviour in the Kubernetes plugin. That wasn’t irresponsible exactly; it was a calculated refusal to spend a Friday proving that 120 pipelines still behaved. The calculation gets less defensible each quarter.&lt;/p&gt;

&lt;p&gt;GitHub Actions has its own supply-chain problem. Marketplace actions are code from strangers with a reassuring logo. We allow approved actions, pin them by SHA, and use reusable workflows for container publishing and deployment. Teams can read those workflows, but they cannot casually add &lt;code&gt;curl | bash&lt;/code&gt; to production release paths.&lt;/p&gt;

&lt;p&gt;GitLab’s includes and templates offer a similar central-control model. We prefer it over Jenkins shared libraries because YAML is less capable of becoming a small programming language with opinions about classpaths. Some people love Jenkins Groovy. We have enough Java already.&lt;/p&gt;

&lt;p&gt;There remains one unresolved argument in our team: whether reusable GitHub workflows are becoming shared libraries with worse local testing. They probably are. We’ve agreed to keep them thin—authentication, artifact naming, policy checks—and leave build commands in each repository. Ask us again after the next 20 migrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick This If Your Team Has These Constraints
&lt;/h2&gt;

&lt;p&gt;Pick &lt;strong&gt;GitHub Actions&lt;/strong&gt; if you have 3–30 engineers, repositories already hosted on GitHub, and mostly standard builds: tests, containers, Terraform plans, and cloud deployments. Start on hosted runners. Use OIDC for cloud access, protect environments, cancel duplicate pull-request runs, and resist adding self-hosted runners for one awkward service.&lt;/p&gt;

&lt;p&gt;Pick &lt;strong&gt;Jenkins on Kubernetes&lt;/strong&gt; if you have 15–80 engineers and a meaningful set of jobs that require private network paths, internal build hardware, custom operating systems, or long-established scripted release steps. Keep the controller small, make agents ephemeral, and assign ownership for every shared library. Do not keep Jenkins merely because the team knows where the blue button is.&lt;/p&gt;

&lt;p&gt;Pick &lt;strong&gt;GitLab CI&lt;/strong&gt; if source code, merge requests, container registry, and security scanning already live in GitLab. For a 10–50 person team, one product for those workflows is a sensible operational trade. We would choose GitLab CI over Jenkins in that setup, but not over GitHub Actions if moving source control is the hidden price.&lt;/p&gt;

&lt;p&gt;For our five-person platform team, the split is 25 repositories on GitHub Actions, nine Jenkins jobs left behind, and a calendar reminder in November to see whether that vendor appliance has finally learned what an API is.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
    </item>
  </channel>
</rss>
