<?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: Oleksandr Kuryzhev</title>
    <description>The latest articles on DEV Community by Oleksandr Kuryzhev (@oleksandr_kuryzhev_42873f).</description>
    <link>https://dev.to/oleksandr_kuryzhev_42873f</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%2F3970301%2Fff42dfb6-af2a-4fc7-968a-54326187a691.jpg</url>
      <title>DEV Community: Oleksandr Kuryzhev</title>
      <link>https://dev.to/oleksandr_kuryzhev_42873f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oleksandr_kuryzhev_42873f"/>
    <language>en</language>
    <item>
      <title>Kubernetes NetworkPolicy Checklist: Locking Down Namespace Isolation</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sun, 16 Aug 2026 07:01:49 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/kubernetes-networkpolicy-checklist-locking-down-namespace-isolation-2el</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/kubernetes-networkpolicy-checklist-locking-down-namespace-isolation-2el</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/16/kubernetes-networkpolicy-checklist-locking-down-namespace-isolation" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;Why this checklist&lt;/h2&gt;



&lt;p&gt;Last audit we ran, the pentest report had one line that made the whole platform team wince: "lateral movement possible between all namespaces." Our cluster had Calico installed, NetworkPolicy support was there, dashboards were green — and yet a compromised pod in a low-trust namespace could reach the billing service directly on port 5432. Nobody had actually written a kubernetes networkpolicy for namespace isolation. The CNI supported it. We just never used it.&lt;/p&gt;

&lt;p&gt;Here's the part people miss: without any NetworkPolicy object applied, Kubernetes networking is default-allow. Every pod can reach every other pod, on any port, across every namespace, full stop. That's the platform's factory setting, and it stays that way until you deploy something that says otherwise.&lt;/p&gt;

&lt;p&gt;NetworkPolicy is also namespace-scoped and purely additive. You can't "half isolate" a namespace by dropping in a few allow rules — if there's no deny-all baseline, your allow rules add nothing, because everything was already open. This is mistake #1 I see constantly: teams write "allow app-a to talk to app-b" and feel like they've done isolation work. They haven't. Without a deny-all first, that rule is decorative.&lt;/p&gt;

&lt;p&gt;What actually forces teams to write this baseline in practice is one of three triggers: a multi-tenant cluster where different teams or customers share nodes, a compliance push (PCI-DSS, SOC2) where an auditor asks "how do you enforce network segmentation between workloads," or exactly what happened to us — a pentest finding lateral movement. If you're reading this before any of those hit you, you're ahead of most teams.&lt;/p&gt;

&lt;h2&gt;The checklist (numbered)&lt;/h2&gt;

&lt;p&gt;This is the order we now apply to every namespace before calling it "isolated." Skipping steps, or doing them out of order, is how you end up with policies that look right in &lt;code&gt;kubectl get networkpolicy&lt;/code&gt; and do nothing in practice.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Default-deny-all ingress.&lt;/strong&gt; Empty &lt;code&gt;podSelector: {}&lt;/code&gt;, &lt;code&gt;policyTypes: [Ingress]&lt;/code&gt;, no rules. This blocks all inbound traffic to every pod in the namespace unless explicitly allowed. Verify: &lt;code&gt;kubectl describe networkpolicy default-deny-all -n &amp;lt;ns&amp;gt;&lt;/code&gt; and confirm ingress is listed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default-deny-all egress.&lt;/strong&gt; Same pattern with &lt;code&gt;Egress&lt;/code&gt;. This is the one people forget — an empty policy without explicit &lt;code&gt;policyTypes: [Egress]&lt;/code&gt; only blocks ingress in some Kubernetes versions, egress stays wide open. Explicit is mandatory here, not optional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit allow for DNS egress.&lt;/strong&gt; CoreDNS lives in &lt;code&gt;kube-system&lt;/code&gt; on 53/UDP and 53/TCP. Skip this and every pod breaks silently right after step 2 lands. Verify: &lt;code&gt;kubectl exec &amp;lt;pod&amp;gt; -- nslookup kubernetes.default&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit allow for intra-namespace pod-to-pod traffic&lt;/strong&gt;, if your app actually needs it (microservices talking within the same namespace). Don't add this reflexively — plenty of namespaces don't need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit allow for ingress controller / mesh sidecar traffic.&lt;/strong&gt; Nginx-ingress or Traefik usually sits in its own namespace; without a rule allowing traffic from there, external requests get silently dropped after your baseline lands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow monitoring scrape traffic.&lt;/strong&gt; Prometheus pulls metrics, but "pull" still requires an ingress rule on the target side. Miss this and your dashboards go dark with zero error in the app logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow egress to specific external endpoints&lt;/strong&gt; instead of &lt;code&gt;0.0.0.0/0&lt;/code&gt;. Scope to actual CIDRs — package registries, external APIs — not "port 443 to anywhere," which defeats the entire point of egress isolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the CNI actually enforces policy.&lt;/strong&gt; kube-proxy alone enforces nothing. Confirm with a live traffic test, not by reading YAML.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reference bundle below covers items 1–4 and 6 for one namespace — apply per-namespace or template it via GitOps.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# baseline-networkpolicy.yaml
# Apply this bundle to EVERY namespace as the isolation baseline.
# kubectl apply -n &amp;lt;namespace&amp;gt; -f baseline-networkpolicy.yaml

---
# 1. Default deny all ingress and egress traffic
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: my-app-ns
spec:
  podSelector: {}          # applies to all pods in the namespace
  policyTypes:
    - Ingress
    - Egress
  # no ingress/egress rules defined = deny everything by default

---
# 2. Allow DNS egress — required after default-deny-egress, or pods can't resolve names
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: my-app-ns
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

---
# 3. Allow ingress from the shared ingress-controller namespace only
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-ingress-controller
  namespace: my-app-ns
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: my-app   # match your ACTUAL pod labels, not assumed ones
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
      ports:
        - protocol: TCP
          port: 8080

---
# 4. Allow monitoring namespace to scrape metrics endpoint
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-prometheus-scrape
  namespace: my-app-ns
spec:
  podSelector: {}
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
      ports:
        - protocol: TCP
          port: 9090
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Commonly missed items&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #1: DNS breaks after you enable egress deny.&lt;/strong&gt; The symptom is almost always the same — &lt;code&gt;dial tcp: lookup &amp;lt;service&amp;gt;: i/o timeout&lt;/code&gt;, and every app in the namespace starts failing at once. Nine times out of ten it's the missing DNS egress rule. We got bitten by this rolling out the baseline to a staging namespace at 4pm on a Friday — three services went red simultaneously and it took twenty minutes to connect the dots back to the policy we'd just applied.&lt;/p&gt;

&lt;p&gt;Namespace labels not being set is the second big one. &lt;code&gt;namespaceSelector&lt;/code&gt; rules depend on the automatically-added label &lt;code&gt;kubernetes.io/metadata.name=&amp;lt;ns&amp;gt;&lt;/code&gt;, which has existed since Kubernetes 1.21. Older, manually-created namespaces sometimes had this label stripped by an overzealous label-cleanup script or a Helm chart that overwrites labels on upgrade. The policy applies fine, shows up in &lt;code&gt;kubectl get networkpolicy&lt;/code&gt;, and matches absolutely nothing. It looks present. It does nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #2: hostNetwork pods and DaemonSets bypass NetworkPolicy entirely.&lt;/strong&gt; kube-proxy, CNI agents, log shippers with &lt;code&gt;hostNetwork: true&lt;/code&gt; — none of that traffic is subject to your policies. If your threat model includes "what if someone gets a shell on a node," your NetworkPolicy baseline gives you zero protection there. That's a host-level hardening problem, not a NetworkPolicy problem — don't let the checklist give you false confidence about it.&lt;/p&gt;

&lt;p&gt;Mistake #2, which is sneaky: policies written against &lt;code&gt;app: myapp&lt;/code&gt; when the Helm chart actually renders &lt;code&gt;app.kubernetes.io/name: myapp&lt;/code&gt;. The selector never matches, the policy is a silent no-op, and it survives code review because nobody diffs pod labels against policy selectors. Always run &lt;code&gt;kubectl get pods -n &amp;lt;ns&amp;gt; --show-labels&lt;/code&gt; before writing the selector, not after.&lt;/p&gt;

&lt;p&gt;Last one: assuming "we have a CNI" means you're covered. Plain Flannel does not enforce NetworkPolicy at all — you need Calico, Cilium, or Weave Net layered on top. Check the &lt;a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/" rel="noopener noreferrer"&gt;official NetworkPolicy docs&lt;/a&gt; for the enforcement caveat; it's easy to skim past.&lt;/p&gt;

&lt;h2&gt;Automation ideas&lt;/h2&gt;

&lt;p&gt;Writing YAML by hand for every namespace doesn't scale, and it drifts the moment someone runs a quick &lt;code&gt;kubectl edit&lt;/code&gt; to unblock a demo. Here's what's worked for us.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lint in CI before merge.&lt;/strong&gt; Cilium ships &lt;code&gt;cilium policy validate&lt;/code&gt;, and for plain Kubernetes NetworkPolicy YAML, a simple OPA/Conftest rule catches the biggest offender: policies missing &lt;code&gt;Egress&lt;/code&gt; from &lt;code&gt;policyTypes&lt;/code&gt;. We run this as a required check on every PR touching &lt;code&gt;networkpolicy/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bake the baseline into namespace creation.&lt;/strong&gt; Instead of trusting humans to remember steps 1–3, use an admission webhook — Kyverno or OPA Gatekeeper — that auto-injects the default-deny and DNS-allow policies the moment a namespace is created. We moved to this after the third time someone spun up a namespace via &lt;code&gt;kubectl create ns&lt;/code&gt; and forgot the whole checklist existed. If you're managing namespaces via GitOps already, check our &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps notes on ArgoCD sync patterns&lt;/a&gt; for how we template the namespace bootstrap bundle alongside app manifests.&lt;/p&gt;

&lt;p&gt;Below is the verification script we run in CI and on-demand after applying the baseline — it's saved us more debugging time than any dashboard.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Quick verification script — run after applying the baseline
# to confirm enforcement actually works (not just "applied").

NS="my-app-ns"
POD=$(kubectl get pod -n "$NS" -l app.kubernetes.io/name=my-app -o jsonpath='{.items[0].metadata.name}')

echo "== Checking DNS resolution (should succeed) =="
kubectl exec -n "$NS" "$POD" -- nslookup kubernetes.default.svc.cluster.local

echo "== Checking cross-namespace access WITHOUT allow rule (should FAIL/timeout) =="
kubectl exec -n "$NS" "$POD" -- curl -m 2 -sv http://some-service.other-ns.svc.cluster.local:80 \
  || echo "Blocked as expected ✅"

echo "== Checking allowed ingress from ingress-nginx namespace =="
kubectl run tmp-curl --rm -i --tty --restart=Never \
  -n ingress-nginx --image=curlimages/curl -- \
  curl -m 2 -sv http://my-app.my-app-ns.svc.cluster.local:8080 \
  || echo "Unexpected block ❌ check namespaceSelector labels"

# Sanity check: does the namespace even have the required label?
kubectl get ns my-app-ns --show-labels | grep kubernetes.io/metadata.name \
  || echo "WARNING: namespace missing metadata.name label — namespaceSelector rules won't match"

# Expected output snippet on success:
# Server:    10.96.0.10
# Address:   10.96.0.10:53
# Name:   kubernetes.default.svc.cluster.local
# Address: 10.96.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For drift detection, we run a scheduled job that diffs live cluster NetworkPolicies against the git-tracked baseline and alerts on manual edits — anyone editing a policy live via &lt;code&gt;kubectl&lt;/code&gt; gets flagged within the hour. And a CI assertion job checks every namespace has both &lt;code&gt;Ingress&lt;/code&gt; and &lt;code&gt;Egress&lt;/code&gt; in &lt;code&gt;policyTypes&lt;/code&gt;, failing the pipeline otherwise. It's a small amount of automation for a large amount of "we didn't actually notice namespace isolation had quietly regressed."&lt;/p&gt;

&lt;p&gt;Worth watching: &lt;code&gt;AdminNetworkPolicy&lt;/code&gt; (KEP-2091) is moving toward GA and adds cluster-level baseline enforcement that doesn't depend on every namespace remembering its own policy set. Check the &lt;a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/#adminnetworkpolicy" rel="noopener noreferrer"&gt;Kubernetes docs on AdminNetworkPolicy&lt;/a&gt; if you're planning next year's roadmap — it's the direction this checklist is eventually heading.&lt;/p&gt;

&lt;p&gt;None of this replaces reviewing your actual kubernetes networkpolicy namespace isolation posture manually at least once a quarter. Automation catches drift; it doesn't catch a threat model that's changed since you wrote the rules.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/kubernetes/" rel="noopener noreferrer"&gt;More Kubernetes cluster hardening and troubleshooting guides&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;Security checklists for production infrastructure&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/gitops/" rel="noopener noreferrer"&gt;GitOps patterns for policy and namespace bootstrap automation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
    <item>
      <title>S3 Lifecycle Policy Mistakes That Quietly Inflate Your AWS Bill</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sat, 15 Aug 2026 07:01:54 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/s3-lifecycle-policy-mistakes-that-quietly-inflate-your-aws-bill-2mjg</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/s3-lifecycle-policy-mistakes-that-quietly-inflate-your-aws-bill-2mjg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/15/s3-lifecycle-policy-mistakes-that-quietly-inflate-your-aws-bill" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Your versioned S3 bucket is quietly billing you for millions of noncurrent objects that no S3 lifecycle policy was ever written to catch. We found this out the hard way during a routine Cost Explorer review — a "small" logging bucket was sitting at 40TB of noncurrent versions nobody knew existed. If you're running compliance workloads, backups, or high-volume logging on S3, these are the rules I now treat as non-negotiable.&lt;/p&gt;

&lt;h2&gt;Split lifecycle rules by prefix and tag, never one rule per bucket&lt;/h2&gt;



&lt;p&gt;A single broad rule with no prefix filter is a blast radius waiting to happen. I've seen a rule meant for &lt;code&gt;logs/&lt;/code&gt; accidentally match &lt;code&gt;logs-backup/&lt;/code&gt; because someone typed a prefix without the trailing slash, and it deleted three weeks of audit data before anyone noticed. Filters can combine one prefix with a tag set using an &lt;code&gt;and&lt;/code&gt; block, so use that instead of loose wildcards whenever object count matters.&lt;/p&gt;

&lt;p&gt;Map rule IDs 1:1 to a naming convention — &lt;code&gt;logs/&lt;/code&gt;, &lt;code&gt;backups/daily/&lt;/code&gt;, &lt;code&gt;compliance/retain/&lt;/code&gt; — so anyone reading the Terraform state knows exactly what a rule targets without cross-referencing the console. Also know your ceiling: AWS allows up to 1,000 lifecycle rules per bucket. If you're running a multi-tenant bucket and approaching that limit, it's a sign you should split into separate buckets, not cram more &lt;code&gt;and&lt;/code&gt; conditions into one rule.&lt;/p&gt;

&lt;h2&gt;Use Object Lock in Compliance mode for anything with a retention mandate&lt;/h2&gt;

&lt;p&gt;Object Lock and a plain S3 lifecycle policy solve different problems — expiration schedules storage cost, Object Lock enforces legal retention that nobody, including root, can override. &lt;strong&gt;Watch out:&lt;/strong&gt; Object Lock can only be enabled at bucket creation time. There's no retrofit; if you forgot it on a bucket that's already holding compliance data, you're migrating objects to a new bucket, not flipping a setting.&lt;/p&gt;

&lt;p&gt;Governance mode lets privileged users shorten or remove a retention period — fine for internal policy, useless for an audit. Compliance mode is the only one that's actually audit-safe, since no principal can undo it before the retention date. It also requires versioning to be enabled, and it interacts directly with expiration rules: a locked version blocks deletion even if an expiration rule fires against it, so a lifecycle rule silently no-ops on protected objects rather than erroring.&lt;/p&gt;

&lt;h2&gt;Transition logs aggressively, expire even more aggressively&lt;/h2&gt;

&lt;p&gt;Logs are high-volume and low-value per object — treat them differently from backups from day one. A tiering path like STANDARD → STANDARD_IA at 30 days → GLACIER_IR at 90 days → expire at 400 days works well for most CloudTrail and ALB access log volumes, but tune the numbers to your actual query patterns, not a copy-pasted default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; STANDARD_IA and Glacier/Glacier IR carry minimum billable storage durations — 30 days for IA, 90 days for Glacier — so transitioning objects that get deleted or re-uploaded before that window closes triggers a prorated early-deletion charge. For logs with a lifespan under 30 days, plain expiration is often cheaper than any transition at all. Model the cost before applying a tiering rule blindly, and give log types their own prefixes so a policy tuned for CloudTrail doesn't accidentally reshape retention for application logs with a different mandate.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# terraform: s3-lifecycle-governance.tf
# Requires: aws provider ~&amp;gt; 5.0, versioning + Object Lock pre-enabled on bucket

resource "aws_s3_bucket_versioning" "compliance" {
  bucket = aws_s3_bucket.compliance.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "compliance" {
  bucket = aws_s3_bucket.compliance.id

  # Rule 1: application logs — aggressive tiering + expiration
  rule {
    id     = "logs-tiering"
    status = "Enabled"

    filter {
      prefix = "logs/"
    }

    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }

    transition {
      days          = 90
      storage_class = "GLACIER_IR"
    }

    expiration {
      days = 400
    }

    # Orphaned multipart uploads from log shippers — always include this
    abort_incomplete_multipart_upload {
      days_after_initiation = 7
    }
  }

  # Rule 2: backups — longer retention, slower tiering
  rule {
    id     = "backups-retention"
    status = "Enabled"

    filter {
      and {
        prefix = "backups/"
        tags = {
          "retention" = "long"
        }
      }
    }

    transition {
      days          = 60
      storage_class = "GLACIER"
    }

    expiration {
      days = 1825 # 5 years, adjust to compliance mandate
    }
  }

  # Rule 3: noncurrent versions — closes the "silent growth" gap
  rule {
    id     = "noncurrent-cleanup"
    status = "Enabled"

    filter {
      prefix = "" # applies bucket-wide
    }

    noncurrent_version_transition {
      noncurrent_days = 30
      storage_class   = "GLACIER_IR"
    }

    noncurrent_version_expiration {
      noncurrent_days = 180
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Watch noncurrent version growth — it's the silent cost and compliance trap&lt;/h2&gt;

&lt;p&gt;Every PUT against a versioned bucket creates a new version, and without an explicit &lt;code&gt;noncurrent_version_expiration&lt;/code&gt; block, that storage grows unbounded and completely invisible in the console UI. This is exactly the mistake that caused our 40TB surprise — versioning had been enabled for durability years earlier, and nobody ever added the matching cleanup rule. It only surfaced when Cost Explorer flagged an unexplained S3 spend jump.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;noncurrent_version_transition&lt;/code&gt; and &lt;code&gt;noncurrent_version_expiration&lt;/code&gt; as their own block, separate from your current-version rules — see Rule 3 in the Terraform above. One more subtlety worth knowing: locked versions are correctly skipped by expiration, but unlocked noncurrent versions in the same bucket still get deleted on schedule, which can create gaps in your retained history if you assumed Object Lock covered everything.&lt;/p&gt;

&lt;h2&gt;Always add an abort-incomplete-multipart-upload rule&lt;/h2&gt;

&lt;p&gt;Orphaned multipart uploads are pure waste, and they're invisible unless you go looking. A backup tool or CI job that gets interrupted mid-upload leaves parts sitting in S3, billed at full STANDARD rate, indefinitely, with no expiration ever applied by default.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;abort_incomplete_multipart_upload { days_after_initiation = 7 }&lt;/code&gt; on every single bucket — no exceptions, no "we'll add it later." Verify it's actually catching things periodically, since lifecycle evaluation isn't instant; it runs roughly once a day, so expect up to 24-48 hours of lag between a rule change and it taking effect.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Validate what's actually configured before assuming it's correct
aws s3api get-bucket-lifecycle-configuration \
  --bucket compliance-prod-logs \
  --query 'Rules[].{ID:ID,Status:Status,Filter:Filter,Expiration:Expiration}' \
  --output table

# Check for orphaned multipart uploads lifecycle hasn't caught yet
aws s3api list-multipart-uploads --bucket compliance-prod-logs \
  --query 'Uploads[].{Key:Key,Initiated:Initiated}' --output table

# Example failure when a rule has a filter but no action — silently no-ops
# {
#   "Error": {
#     "Code": "InvalidArgument",
#     "Message": "Found rule without expiry or transition"
#   }
# }

# Audit noncurrent version bytes manually (or use S3 Storage Lens for this at scale)
aws s3api list-object-versions --bucket compliance-prod-logs \
  --query 'Versions[?IsLatest==`false`].[Key,Size]' --output text | \
  awk '{sum+=$2} END {print "Noncurrent bytes:", sum}'
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Lifecycle rules are not access control — pair them with deny policies&lt;/h2&gt;

&lt;p&gt;An S3 lifecycle policy expires objects on a schedule; it does nothing to stop someone from deleting them manually five minutes earlier. For compliance buckets, add an explicit bucket policy &lt;code&gt;Deny&lt;/code&gt; on &lt;code&gt;s3:DeleteObject&lt;/code&gt; and &lt;code&gt;s3:DeleteObjectVersion&lt;/code&gt; for every principal outside a documented break-glass role, and back that up with SCP-level guardrails at the OU level so a bucket policy edit can't quietly undo the protection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security note worth flagging:&lt;/strong&gt; lifecycle-driven deletions don't appear in CloudTrail as a delete action by a principal — they show up as "Lifecycle Expiration" events instead. If your audit tooling only watches for &lt;code&gt;DeleteObject&lt;/code&gt; API calls, expirations will slip through unnoticed. Plan for that by enabling S3 server access logs or an EventBridge rule for object expiration events if you need a complete audit trail. We cover the Terraform side of locking down state and access patterns like this in more depth in our &lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Terraform security notes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;Validate before you ship — dry-run and inventory-audit every rule change&lt;/h2&gt;

&lt;p&gt;There's no native dry-run flag for S3 lifecycle configuration, so test scope changes on a cloned prefix in a non-production bucket first. Run &lt;code&gt;aws s3api get-bucket-lifecycle-configuration&lt;/code&gt; before and after a change and diff the output — it's tedious, but it's the only way to confirm the JSON you shipped is the JSON that's actually active, since Terraform state and reality drift more often than people admit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha that catches almost everyone eventually:&lt;/strong&gt; lifecycle transitions and expirations evaluate against an object's creation date, not its last-modified date. Restoring an object from Glacier or re-uploading it resets "last accessed" but not creation date, which means it can transition again on a schedule you didn't expect. Enable S3 Inventory reports to track age and storage-class distribution over time, and if you're managing this across many accounts, S3 Storage Lens's free tier will surface lifecycle rule counts and noncurrent version bytes per bucket without writing a single custom script.&lt;/p&gt;

&lt;p&gt;None of this is exotic — it's the same handful of edge cases repeating across every account we've audited. Get the S3 lifecycle policy basics right per bucket (prefix scoping, noncurrent cleanup, multipart abort, Object Lock where it's actually required), and the rest is just tuning tiering windows to match how your data is actually used. For the full lifecycle configuration reference, AWS's own &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html" rel="noopener noreferrer"&gt;S3 Object Lifecycle Management docs&lt;/a&gt; and the &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html" rel="noopener noreferrer"&gt;Object Lock guide&lt;/a&gt; are worth bookmarking — they're more precise on edge cases than most blog posts, including this one.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/aws/" rel="noopener noreferrer"&gt;More AWS cost and IAM troubleshooting from production incidents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Terraform state security and drift detection patterns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;Security guardrails for compliance and audit-grade AWS accounts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>Ansible Zero-Downtime Deployment: Draining the Load Balancer Right</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:01:55 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/ansible-zero-downtime-deployment-draining-the-load-balancer-right-35j6</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/ansible-zero-downtime-deployment-draining-the-load-balancer-right-35j6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/14/ansible-zero-downtime-deployment-draining-the-load-balancer-right" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;The scenario&lt;/h2&gt;

&lt;p&gt;We had a Flask API running on 8 app servers behind HAProxy, deployed with a straightforward Ansible playbook. It worked fine for a year — until traffic grew and someone finally noticed the pattern: every deploy, without exception, produced a 20-40 second window of 502s. Nobody had flagged it because deploys were scheduled at 3am specifically to dodge the pain, which tells you everything about how "acceptable" the workaround had become.&lt;/p&gt;

&lt;p&gt;The root cause was embarrassingly simple. The playbook used &lt;code&gt;serial: 100%&lt;/code&gt;, meaning Ansible hit every host at once, restarted every service at once, and HAProxy's health checks failed simultaneously across the entire backend pool. There was no ansible zero-downtime deployment strategy in place — just a full-fleet restart dressed up as automation. Users hit dead backends for the exact duration it took systemd to bring the process back up and pass its first health check.&lt;/p&gt;

&lt;p&gt;The fix isn't exotic. It's &lt;code&gt;serial&lt;/code&gt; plus &lt;code&gt;max_fail_percentage&lt;/code&gt; plus explicit load balancer deregistration, wired together so that a node stops receiving traffic before it gets touched, and only rejoins the pool once it's actually ready — not just "port open." That last distinction matters more than people expect, and I'll get to why.&lt;/p&gt;

&lt;h2&gt;Prerequisites&lt;/h2&gt;

&lt;p&gt;Before touching the playbook, make sure the environment actually supports this pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ansible core 2.15+ — &lt;code&gt;serial&lt;/code&gt; and &lt;code&gt;max_fail_percentage&lt;/code&gt; are play-level keywords, not task-level, and older versions have inconsistent batch-rounding behavior.&lt;/li&gt;
&lt;li&gt;An inventory group (&lt;code&gt;webservers&lt;/code&gt;) with SSH access and become privileges configured.&lt;/li&gt;
&lt;li&gt;A load balancer that exposes some form of programmatic control. We used HAProxy's runtime socket (&lt;code&gt;/var/run/haproxy/admin.sock&lt;/code&gt;), but if you're on AWS, the &lt;code&gt;amazon.aws.elb_target&lt;/code&gt; module (collection &lt;code&gt;amazon.aws&lt;/code&gt; &amp;gt;= 5.0.0) does the equivalent against an ALB target group.&lt;/li&gt;
&lt;li&gt;An app-level &lt;code&gt;/healthz&lt;/code&gt; endpoint that returns 200 only when the app is actually warmed up — database pool initialized, cache connected — not just "process is alive."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of these are missing, fix that first. Building rolling deploy logic on top of a health check that lies is worse than not having one, because it gives you false confidence right before it breaks in production.&lt;/p&gt;

&lt;h2&gt;Step 1 — Structure the playbook with serial and batching&lt;/h2&gt;



&lt;p&gt;The skeleton starts at the play level, before any load balancer logic gets involved. &lt;code&gt;serial: "25%"&lt;/code&gt; tells Ansible to work through the inventory in batches, and it rounds up — on an 8-host inventory that's batches of 2, but on a 7-host inventory, 25% rounds to batches of 2 as well since Ansible ceils the fraction rather than truncating it. Don't assume the math lines up neatly; check it against your actual host count.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max_fail_percentage: 0&lt;/code&gt; combined with &lt;code&gt;any_errors_fatal: true&lt;/code&gt; gives the strictest possible behavior: if a single host in a batch fails, the entire run stops immediately, including batches that haven't started yet. That's intentional. A partial bad rollout across half your fleet is a much worse night than a deploy that stops early.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;order: sorted&lt;/code&gt; matters more than it looks — default inventory order can shift between runs if you're using a dynamic inventory script, which makes batch membership non-deterministic. That's a debugging nightmare when you're trying to reproduce a failure that only happened on "whichever three hosts landed in batch two."&lt;/p&gt;

&lt;h2&gt;Step 2 — Deregister from the load balancer before touching the host&lt;/h2&gt;

&lt;p&gt;This is the step naive rolling deploys skip, and it's the number one mistake I see. Restarting the service before pulling the node out of rotation means HAProxy is still routing live requests to a process that's mid-restart. You don't get a clean 502 — you get dropped connections mid-request, which is worse for anything transactional.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;code&gt;pre_tasks&lt;/code&gt; block that disables the backend server first, then pauses long enough for in-flight connections to drain before anything else happens.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;pre_tasks:
  - name: Deregister node from HAProxy backend
    ansible.builtin.shell: |
      echo "disable server backend_app/{{ inventory_hostname }}" | \
      socat stdio /var/run/haproxy/admin.sock
    changed_when: true

  - name: Wait for in-flight connections to drain
    ansible.builtin.pause:
      seconds: "{{ drain_wait }}"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The drain wait should roughly match your load balancer's connection draining timeout. AWS ALB defaults to a 300-second deregistration delay, which is absurd for a rolling deploy — most teams tune it down to 15-30 seconds. Whatever value you land on, keep the Ansible pause in sync with it, or you'll drain traffic on paper while the LB config still thinks it needs five minutes.&lt;/p&gt;

&lt;h2&gt;Step 3 — Deploy, restart, and health-check before re-registering&lt;/h2&gt;

&lt;p&gt;Once the node is out of rotation, deploy the artifact, restart the service, and — critically — wait on a real readiness check before doing anything else. This is where the second big mistake shows up: checking &lt;code&gt;wait_for: port=8080&lt;/code&gt; and calling it done. A port being open tells you the process bound to it. It tells you nothing about whether the DB connection pool finished initializing or the JIT warmed up. I've watched a "successful" deploy re-register a node into the pool and immediately start throwing 500s for the next ten seconds, because the health check was checking the wrong thing.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;tasks:
  - name: Deploy latest release artifact
    ansible.builtin.unarchive:
      src: "/opt/releases/app-{{ app_version }}.tar.gz"
      dest: "/opt/app/current"
      remote_src: false

  - name: Restart app service
    ansible.builtin.systemd:
      name: myapp
      state: restarted
      daemon_reload: true

  - name: Wait for app to report healthy
    ansible.builtin.uri:
      url: "{{ healthcheck_url }}"
      status_code: 200
      return_content: true
    register: health
    until: &amp;gt;
      health.status == 200 and
      (health.content | from_json).status == "ok"
    retries: 10
    delay: 3
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;/healthz&lt;/code&gt; response should include the dependencies that actually matter — something like &lt;code&gt;{"status":"ok","db":"connected","cache":"connected"}&lt;/code&gt; — not just process liveness. If you're running DB migrations as part of the deploy, keep them in a separate play with &lt;code&gt;run_once: true&lt;/code&gt;, not inline in this one. If the first host in the first batch runs migrations and then fails before the app deploy step completes, you're left with a schema change applied but no app deployed to match it — a nasty state to debug at 3am.&lt;/p&gt;

&lt;h2&gt;Step 4 — Re-register and move to next batch&lt;/h2&gt;

&lt;p&gt;Only after the health check passes does the node go back into rotation. Re-enable it on HAProxy, add a short soak period, then let Ansible move to the next batch.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;post_tasks:
  - name: Re-register node with HAProxy backend
    ansible.builtin.shell: |
      echo "enable server backend_app/{{ inventory_hostname }}" | \
      socat stdio /var/run/haproxy/admin.sock
    changed_when: true

  - name: Soak period before next batch
    ansible.builtin.pause:
      seconds: 10
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;That soak period isn't decorative. It gives your monitoring a window to catch a regression on this one node before the next batch also goes down for deployment — cheap insurance against compounding a mistake across the whole fleet. If you need finer-grained concurrency control within a batch — say, only letting one host at a time run a heavy cache-warm task even though &lt;code&gt;serial&lt;/code&gt; allows three — &lt;code&gt;throttle: N&lt;/code&gt; on that specific task overrides the batch concurrency without changing your overall rollout shape.&lt;/p&gt;

&lt;p&gt;Also worth flagging on the security side: whatever credentials Ansible uses to talk to the LB — HAProxy socket permissions or an AWS IAM role — should be scoped to target group registration only, not full ELB admin. Store them via Ansible Vault or an IAM role, never plaintext in inventory. It's an easy thing to overlook once the deploy pipeline is "working."&lt;/p&gt;

&lt;h2&gt;Verify and test&lt;/h2&gt;

&lt;p&gt;Don't take zero-downtime on faith — prove it. Run a continuous load test against the LB VIP for the duration of the deploy and check for non-2xx responses:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;hey -z 90s -c 10 http://lb.internal/ | tee load_result.txt
grep -E "\[[45][0-9]{2}\]" load_result.txt &amp;amp;&amp;amp; echo "FOUND ERRORS" || echo "CLEAN RUN"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In parallel, watch the HAProxy stats socket to confirm nodes actually transition to MAINT and back to UP rather than dropping out silently:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;watch -n1 'echo "show stat" | socat stdio /var/run/haproxy/admin.sock | \
  awk -F"," "{print \$2, \$18}"'

# Expected during a healthy rolling deploy:
# node1  UP
# node2  MAINT   &amp;lt;- briefly, while deregistered
# node3  UP
# node2  UP      &amp;lt;- back after health check passes
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then SSH into the host being deployed and confirm it shut down gracefully instead of getting killed mid-request:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;ssh node2 "journalctl -u myapp --since '2 min ago' | grep -i 'sigterm|shutting down'"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;That last check ties back to your systemd unit — set &lt;code&gt;TimeoutStopSec=30&lt;/code&gt; and make sure the app actually handles SIGTERM by finishing in-flight requests, or you'll get hard kills even after LB deregistration if the app takes too long to drain on its own. Finally, before you ever point this at production, run &lt;code&gt;ansible-playbook site.yml --limit webservers --check&lt;/code&gt; against staging, then a real run with &lt;code&gt;-vv&lt;/code&gt; so you can see the exact HTTP/socket calls hitting the LB before they touch anything that matters. See the &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS&lt;/a&gt; archive for more on load balancer health check patterns if you're running this against something other than HAProxy.&lt;/p&gt;

&lt;h2&gt;Closing&lt;/h2&gt;

&lt;p&gt;Zero-downtime isn't a flag you flip — it's a chain of three things that all have to hold at once: batched rollout via &lt;code&gt;serial&lt;/code&gt;, explicit load balancer state changes wrapping the actual deploy, and a health check that means "ready" instead of just "alive." Drop any one of those and you silently reintroduce the exact outage window you thought you'd fixed, and it usually won't show up until someone's staring at 502 logs wondering why the "zero-downtime deploy" isn't. Once this pattern is solid on a single playbook, the natural next step is wiring it into CI — GitHub Actions or GitLab pipelines with a manual approval gate between batches — so a human can eyeball metrics before the rollout continues past the first few hosts. For more on the underlying HAProxy and load balancer mechanics, the &lt;a href="https://www.haproxy.org/#docs" rel="noopener noreferrer"&gt;HAProxy documentation&lt;/a&gt; and the &lt;a href="https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_strategies.html" rel="noopener noreferrer"&gt;Ansible strategies guide&lt;/a&gt; are worth keeping bookmarked.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/ansible/" rel="noopener noreferrer"&gt;More Ansible playbook patterns and bootstrap troubleshooting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/ci-cd/" rel="noopener noreferrer"&gt;CI/CD pipeline design and approval gate patterns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/kubernetes/" rel="noopener noreferrer"&gt;Kubernetes rolling update and rollout strategies for comparison&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Cloudflare Origin Hardening Checklist: Firewall, Bots, Strict SSL</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:02:06 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/cloudflare-origin-hardening-checklist-firewall-bots-strict-ssl-dan</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/cloudflare-origin-hardening-checklist-firewall-bots-strict-ssl-dan</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/13/cloudflare-origin-hardening-checklist-firewall-bots-strict-ssl" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;Why this checklist&lt;/h2&gt;



&lt;p&gt;A client of mine once showed me their Cloudflare dashboard during what they thought was a DDoS attack. Traffic graphs were flat. Zero anomalies. Meanwhile their origin server's CPU was pegged at 100% and the app was falling over every few minutes. The attacker hadn't gone through Cloudflare at all — they'd found the origin IP and were hitting it directly, completely bypassing the WAF, the rate limiting, the bot protection, everything. That's the moment I started treating cloudflare origin hardening as a separate discipline from "just turning Cloudflare on."&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth: proxying your DNS through Cloudflare (the orange cloud icon) does not protect your origin server by itself. It protects requests that go &lt;em&gt;through&lt;/em&gt; Cloudflare's edge. If someone finds your real IP — via old A records in DNS history tools like SecurityTrails, via certificate transparency logs on crt.sh, via a misconfigured mail server SPF record, or just by brute-forcing common subdomains before you enabled the proxy — they can talk directly to your server. No WAF, no rate limits, no bot challenge. Just raw TCP straight to your box.&lt;/p&gt;

&lt;p&gt;This checklist closes three specific gaps: network-level access control (who can even reach the origin's IP), request-level filtering (bots, malicious payloads, brute force), and TLS trust between the edge and the origin (so the "encrypted" connection Cloudflare shows you is actually meaningful). If you only do one of these three, you haven't done cloudflare origin hardening — you've done a fraction of it.&lt;/p&gt;

&lt;h2&gt;The checklist (numbered)&lt;/h2&gt;

&lt;p&gt;Work through these in order. Each item is independently verifiable — don't move to the next until you've confirmed the current one actually works, not just that you clicked the toggle.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Restrict the origin firewall to Cloudflare's published IP ranges.&lt;/strong&gt; Pull the current lists from &lt;a href="https://www.cloudflare.com/ips-v4" rel="noopener noreferrer"&gt;cloudflare.com/ips-v4&lt;/a&gt; and &lt;code&gt;ips-v6&lt;/code&gt;, and lock down your security group / iptables to allow ports 80 and 443 only from those ranges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cover IPv6, not just IPv4.&lt;/strong&gt; If your origin has an AAAA record and Cloudflare is proxying it, the IPv6 range needs the same restriction. This gets forgotten constantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict management ports separately.&lt;/strong&gt; SSH (22), database ports, admin panels — none of these should sit behind "Cloudflare-only" rules since Cloudflare doesn't proxy them. Lock these to your VPN or bastion IP, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with a direct curl test.&lt;/strong&gt; Run &lt;code&gt;curl -H "Host: yourdomain.com" https://ORIGIN_IP/&lt;/code&gt; from a machine outside your office/VPN. It should time out or get refused. If it returns a 200, your firewall rules aren't scoped correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable Authenticated Origin Pulls.&lt;/strong&gt; This makes your origin only accept requests carrying a client certificate signed by Cloudflare. Import Cloudflare's origin-pull CA cert to &lt;code&gt;/etc/nginx/certs/cloudflare-origin-pull-ca.pem&lt;/code&gt; and set &lt;code&gt;ssl_verify_client on;&lt;/code&gt; in your nginx server block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set SSL/TLS mode to Full (strict).&lt;/strong&gt; "Full" accepts any certificate, including self-signed junk — it validates encryption, not identity. Only "Full (strict)" checks the cert chain against a trusted CA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issue a Cloudflare Origin CA certificate&lt;/strong&gt; (15-year validity, free, via the dashboard under SSL/TLS &amp;gt; Origin Server or the &lt;code&gt;/certificates&lt;/code&gt; API) and install it on the origin. This cert is only trusted between Cloudflare and your server — don't try to use it for anything public-facing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Double check "Always Use HTTPS" isn't paired with Flexible mode.&lt;/strong&gt; Flexible + Always HTTPS is a classic redirect-loop combo (&lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt;) if your origin doesn't actually serve HTTPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on Bot Fight Mode&lt;/strong&gt; (free tier, coarse) or &lt;strong&gt;Super Bot Fight Mode&lt;/strong&gt; (Pro/Business, JS-based detection) depending on budget. Enterprise gets ML-based Bot Management if you need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add WAF managed rules&lt;/strong&gt; and set them to "Log" first, not "Block." Review false positives for at least a week before flipping the switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add rate limiting on sensitive endpoints&lt;/strong&gt; — login, password reset, API auth — separate from general WAF rules. Budget for this; Free/Pro plans cap you around 10 rate limit rules as of 2024.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicitly verify WAF coverage for WebSocket and gRPC traffic.&lt;/strong&gt; Some managed rulesets don't inspect &lt;code&gt;Upgrade: websocket&lt;/code&gt; requests by default, leaving a quiet gap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's a Terraform module that codifies most of the above so it isn't a one-time dashboard exercise:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# main.tf — Cloudflare origin hardening baseline via Terraform
# Provider version pinned to avoid v4-&amp;gt;v5 breaking changes in zone_settings resources
terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~&amp;gt; 4.30"
    }
  }
}

variable "zone_id" {
  type = string
}

# 1. Force strict SSL between edge and origin — never use "flexible" in prod
resource "cloudflare_zone_settings_override" "strict_ssl" {
  zone_id = var.zone_id
  settings {
    ssl                      = "strict"
    always_use_https         = "on"
    min_tls_version          = "1.2"
    tls_1_3                  = "on"
    automatic_https_rewrites = "on"
  }
}

# 2. Enable Authenticated Origin Pulls at the zone level
resource "cloudflare_authenticated_origin_pulls" "origin_pull" {
  zone_id = var.zone_id
  enabled = true
}

# 3. Enable Super Bot Fight Mode (requires Pro plan or higher)
resource "cloudflare_bot_management" "bots" {
  zone_id                = var.zone_id
  fight_mode              = true
  using_latest_model      = true
  suppress_session_score  = false
}

# 4. Rate limit login endpoint — 20 req/min per IP, then challenge
resource "cloudflare_rate_limit" "login_protect" {
  zone_id   = var.zone_id
  threshold = 20
  period    = 60
  match {
    request {
      url_pattern = "example.com/login*"
      schemes     = ["HTTPS"]
      methods     = ["POST"]
    }
  }
  action {
    mode    = "challenge"
    timeout = 600
  }
  disabled = false
}

# 5. Firewall rule: block anything failing the authenticated origin pull check
resource "cloudflare_ruleset" "origin_only" {
  zone_id = var.zone_id
  name    = "reject-non-cf-origin-checks"
  kind    = "zone"
  phase   = "http_request_firewall_custom"

  rules {
    action      = "block"
    expression  = "(not cf.tls_client_auth.cert_verified)"
    description = "Block requests failing authenticated origin pull check"
    enabled     = true
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Commonly missed items&lt;/h2&gt;

&lt;p&gt;Even teams that "complete" the checklist above leave doors open. I've audited enough setups to see the same four gaps repeatedly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale IP ranges.&lt;/strong&gt; Cloudflare updates its published IP list occasionally, without much announcement. If your firewall rules were hardcoded from a Terraform apply six months ago, you're either blocking legitimate Cloudflare edge nodes (causing intermittent 5xx errors that look random) or leaving gaps that an attacker could theoretically slot into. Watch out for this — it's silent until it isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Port 80 left wide open.&lt;/strong&gt; This is the mistake I see most. Teams lock 443 down to Cloudflare IPs and completely forget port 80. Result: plaintext HTTP requests reach the origin directly, bypassing the redirect-to-HTTPS logic that only exists at the edge. Your "hardened" origin is still accepting raw connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IPv6 origin address exposure.&lt;/strong&gt; Same story as above but easier to miss because most people mentally model their infrastructure as IPv4-only. If your host has an AAAA record and IPv6 firewall rules weren't updated alongside IPv4, you've got an open backdoor nobody's watching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cert expiry with no monitoring.&lt;/strong&gt; Cloudflare Origin CA certs last 15 years, so that's usually fine. But teams that use a public cert (Let's Encrypt, 90-day validity) on "Full (strict)" mode and never wire up renewal specifically for the origin-facing cert will eventually see error 526 — Invalid SSL Certificate. It happens quietly. No alert, no dashboard warning, just a slow bleed of failed requests until someone notices traffic dropped. I stopped using Let's Encrypt on origins behind Cloudflare after this bit a client twice — Origin CA certs with a 15-year runway remove the whole class of problem.&lt;/p&gt;

&lt;p&gt;Also worth knowing: error 525 means the SSL handshake between edge and origin failed outright (usually a cipher mismatch or missing intermediate cert), while 526 specifically means the cert itself is invalid or expired under strict mode. Different root causes, same symptom of "site is down and Cloudflare's error page doesn't tell you why."&lt;/p&gt;

&lt;h2&gt;Automation ideas&lt;/h2&gt;

&lt;p&gt;A checklist you run once during setup rots. Cloudflare origin hardening needs to be enforced continuously, not remembered.&lt;/p&gt;

&lt;p&gt;Codify the settings above in Terraform (see the module earlier) so SSL mode, WAF rules, and Authenticated Origin Pulls live in version control instead of dashboard clicks nobody documented. Check the &lt;a href="https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs" rel="noopener noreferrer"&gt;Cloudflare Terraform provider docs&lt;/a&gt; for the current resource names — v4 renamed a few things and pinning your provider version avoids surprise breaking changes on the next &lt;code&gt;terraform init&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For the IP range rot problem, run a scheduled job — GitHub Actions cron or a plain crontab entry — that pulls the current lists from &lt;code&gt;ips-v4&lt;/code&gt;/&lt;code&gt;ips-v6&lt;/code&gt; and diffs them against what's in your security group. Alert on any change instead of silently applying it; you want a human to glance at what changed before it goes live.&lt;/p&gt;

&lt;p&gt;And build this into CI as a smoke test after every deploy — fail the pipeline if the origin is directly reachable:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
#!/usr/bin/env bash
# verify_origin_lockdown.sh — CI smoke test for origin hardening
# Run in pipeline after deploy; fails build if origin is directly reachable

ORIGIN_IP="203.0.113.45"     # replace with real origin IP, ideally from Terraform output
DOMAIN="example.com"

echo "== Test 1: direct IP access should be refused =="
STATUS=$(curl -o /dev/null -s -w "%{http_code}" --max-time 5 \
  -H "Host: ${DOMAIN}" "https://${ORIGIN_IP}/" -k || echo "TIMEOUT")

if [[ "$STATUS" == "TIMEOUT" || "$STATUS" == "000" ]]; then
  echo "PASS: origin unreachable directly ($STATUS)"
else
  echo "FAIL: origin responded directly with HTTP $STATUS — firewall misconfigured"
  exit 1
fi

echo "== Test 2: SSL mode check via edge =="
curl -sI "https://${DOMAIN}/" | grep -i "strict-transport-security" \
  &amp;amp;&amp;amp; echo "PASS: HSTS present" \
  || echo "WARN: HSTS header missing — check Always Use HTTPS + HSTS settings"

echo "== Test 3: confirm cert issuer is Cloudflare-trusted chain (origin side) =="
openssl s_client -connect "${ORIGIN_IP}:443" -servername "${DOMAIN}" /dev/null \
  | openssl x509 -noout -issuer -dates

# Expected: issuer should reference "CloudFlare Origin SSL Certificate Authority"
# and dates should show &amp;gt;30 days remaining before "notAfter"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This turns cloudflare origin hardening from a one-time audit into something your pipeline actively guards. The nginx side of Authenticated Origin Pulls is documented in the &lt;a href="https://nginx.org/en/docs/http/ngx_http_ssl_module.html" rel="noopener noreferrer"&gt;nginx ssl module docs&lt;/a&gt; if you need to fine-tune &lt;code&gt;ssl_verify_client&lt;/code&gt; beyond the basic "on" setting. For more on layering rate limiting and WAF decisions correctly, I wrote about picking between nginx-level limits and a full API gateway on &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt; — worth a read if you're deciding where that logic should live.&lt;/p&gt;

&lt;p&gt;The core lesson: a green Cloudflare dashboard means nothing if your origin's real IP is one DNS history lookup away from being public knowledge. Treat the origin as its own attack surface, not just a backend behind a proxy.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;More origin hardening and infra security checklists&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Terraform patterns for codifying cloud security settings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/monitoring/" rel="noopener noreferrer"&gt;Alerting setups to catch silent cert expiry and traffic anomalies&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Nginx Rate Limiting vs API Gateway: Picking the Right Layer</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Wed, 12 Aug 2026 07:02:03 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/nginx-rate-limiting-vs-api-gateway-picking-the-right-layer-21kc</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/nginx-rate-limiting-vs-api-gateway-picking-the-right-layer-21kc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/12/nginx-rate-limiting-vs-api-gateway-picking-the-right-layer" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Your nginx.conf can block 90% of API abuse for free — so why do teams rush to pay for Kong or Cloudflare Enterprise before they even need it? I've watched this happen at three different companies now: a scraper burst hits /search, credential-stuffing hammers /login, or a "trusted" third-party integrator decides your fair-use policy is more of a suggestion. Someone panics, opens a ticket for "add rate limiting," and two sprints later there's a Redis cluster and a Kong Enterprise line item nobody asked for. Nginx rate limiting was sitting there the whole time, already installed, already free.&lt;/p&gt;

&lt;h2&gt;When you face this choice&lt;/h2&gt;



&lt;p&gt;The trigger is almost always the same shape: traffic that looks legitimate at the edge but isn't sustainable at the backend. A scraper hammering your product catalog at 200 req/s from a rotating pool of IPs. A credential-stuffing run against /login that's technically valid HTTP but is clearly not a human. Or the more mundane case — a partner integration you signed off on eighteen months ago that's now sending 5x the agreed volume because their product grew and nobody told you.&lt;/p&gt;

&lt;p&gt;At that point you're at a fork. Option one: solve it in nginx.conf, close to the socket, cheap and static. Option two: push the logic to a gateway or edge layer that's dynamic, distributed, and — let's be honest — usually costs money or ops overhead you didn't budget for.&lt;/p&gt;

&lt;p&gt;The thing that actually forces the decision isn't philosophy, it's topology. If you're running a single nginx instance in front of your API, native rate limiting just works — it has one process, one shared memory zone, one source of truth. The moment you scale to multiple nginx or LB nodes without shared state, that single-node assumption breaks, and you need to decide whether to bolt on synchronization or move the whole problem to a layer built for distributed counting. That's the real question behind "nginx rate limiting vs gateway," and it's worth answering honestly before you write a single limit_req_zone line.&lt;/p&gt;

&lt;h2&gt;Option A: Native Nginx rate limiting (limit_req / limit_conn)&lt;/h2&gt;

&lt;p&gt;Nginx has shipped &lt;code&gt;ngx_http_limit_req_module&lt;/code&gt; since version 1.1.8 — there's nothing to install on a modern 1.24/1.25 box, it's compiled in by default. That alone makes it the obvious starting point. The pros are hard to argue with: sub-millisecond overhead, request rejection happens at L7 before anything touches your app servers, and the whole config lives in one file you can read top to bottom.&lt;/p&gt;

&lt;p&gt;Performance-wise, zone lookups are O(1) against a shared memory hash table, so even at 10k+ req/s the CPU cost is negligible. The real cost — and this bit me once — is logging. If you don't set &lt;code&gt;limit_req_log_level warn;&lt;/code&gt;, every single rejection writes a line to error.log, and under a sustained attack that log file grows fast enough to fill a disk.&lt;/p&gt;

&lt;p&gt;Now the cons, and they're real. Rate limiting is per-instance — the memory zone lives in one nginx process, so if you run multiple nginx or LB nodes, each one counts independently and your effective global limit multiplies by node count. Limits are IP-based by default, which quietly breaks behind NAT, corporate proxies, or a CDN that masks the real client IP — everyone ends up hashed to the load balancer's address unless you configure &lt;code&gt;real_ip_header&lt;/code&gt; correctly. And there's no built-in per-API-key or per-JWT-claim limiting; you're writing map/geo hacks to fake it, which gets ugly fast once you have more than a couple of tiers.&lt;/p&gt;

&lt;h2&gt;Option B: Gateway/edge-based rate limiting (Kong, Cloudflare, or OpenResty+Redis)&lt;/h2&gt;

&lt;p&gt;This is where you go when "per IP" stops being granular enough. A Redis-backed layer — whether that's Kong's rate-limiting plugin with &lt;code&gt;policy=redis&lt;/code&gt;, or hand-rolled OpenResty with &lt;code&gt;lua-resty-limit-traffic&lt;/code&gt; — gives you true distributed counting across N nodes. Per-API-key, per-JWT, per-tenant limits work out of the box with Kong plugins or Cloudflare rulesets, which is exactly what you need once you're billing customers by usage tier.&lt;/p&gt;

&lt;p&gt;Cloudflare specifically has the added benefit of blocking abusive traffic before it reaches your infrastructure at all — that saves bandwidth and compute, not just app-layer noise. But it's not free: Cloudflare's Rate Limiting Rules are billed per rule/request volume starting at the Pro plan; the free tier only gives you basic firewall rules, not granular rate limiting. Kong OSS's local-counter default doesn't help you across nodes either — you need the Redis policy, and that policy has noticeably less config flexibility than what Kong Enterprise offers.&lt;/p&gt;

&lt;p&gt;The operational cons stack up too. Every request now takes a Redis round-trip — roughly 1-2ms with lua-resty-limit-traffic — which is fine until Redis has a bad night and now your rate limiter is a single point of failure for your entire API. You're also running and monitoring another service, and if you're going the Kong Enterprise route, licensing cost is a real line item, not a rounding error.&lt;/p&gt;

&lt;p&gt;Here's the native config I run as a baseline on most APIs before reaching for anything heavier. It covers per-IP request limiting, connection capping for slow-POST protection, and a whitelist for internal/partner traffic:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# /etc/nginx/conf.d/api-ratelimit.conf
# Option A: native nginx rate limiting — single-node setup

# Define shared memory zone: 10m ≈ 160k tracked client IPs
# rate=10r/s is the sustained limit per IP
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

# Separate zone for concurrent connection capping (slow-POST protection)
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

# Whitelist internal monitoring/partner IPs — bypasses limiting entirely
geo $limit_whitelist {
    default 0;
    10.0.0.0/8 1;      # internal network
    203.0.113.5/32 1;  # trusted partner
}
map $limit_whitelist $limit_key {
    0 $binary_remote_addr;
    1 "";  # empty key = not tracked/limited
}
limit_req_zone $limit_key zone=api_limit_wl:1m rate=10r/s;

server {
    listen 443 ssl;
    server_name api.example.com;

    # If sitting behind a CDN/LB, must trust the real client IP header
    set_real_ip_from 10.0.0.0/8;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    location /v1/ {
        # burst allows short spikes, nodelay serves them immediately
        # instead of queuing (avoids added latency for legit bursts)
        limit_req zone=api_limit burst=20 nodelay;
        limit_conn conn_limit 5;

        # return 429 instead of nginx's default 503
        limit_req_status 429;
        limit_conn_status 429;

        # avoid flooding error.log on every rejection
        limit_req_log_level warn;

        add_header Retry-After 1 always;
        proxy_pass http://backend_upstream;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Two gotchas here that I've seen take down or under-protect production more than once. First: nginx's default rejection status is &lt;strong&gt;503 Service Unavailable&lt;/strong&gt;, not 429. If you don't explicitly set &lt;code&gt;limit_req_status 429;&lt;/code&gt;, client retry logic that expects a proper 429 with &lt;code&gt;Retry-After&lt;/code&gt; will misbehave — some clients treat 503 as "server is down" and back off way longer than needed, others retry immediately and make things worse. Second: if you're sitting behind Cloudflare or an ALB and you rate limit on &lt;code&gt;$binary_remote_addr&lt;/code&gt; without configuring &lt;code&gt;real_ip_header&lt;/code&gt; and &lt;code&gt;set_real_ip_from&lt;/code&gt;, every single request appears to come from the load balancer's IP. You'll rate limit your entire user base as if it were one client — I've seen this take a service to its knees within minutes of deploy.&lt;/p&gt;

&lt;p&gt;Always run &lt;code&gt;nginx -t&lt;/code&gt; before reload. A missing &lt;code&gt;m&lt;/code&gt; or &lt;code&gt;k&lt;/code&gt; suffix on a zone size fails silently in some nginx versions and defaults to a tiny zone — you won't notice until an attack with high IP cardinality evicts old entries via LRU and lets abuse through under sustained load. Test with a quick loop:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# Quick test: hammer the endpoint and observe 429s kick in after burst

for i in $(seq 1 30); do
  curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/v1/ping
done

# Expected output (rate=10r/s, burst=20, nodelay):
# 200
# 200
# ... (first ~20 pass due to burst allowance)
# 429
# 429
# 429
# ...

# Check nginx error log for the rejection reason:
tail -f /var/log/nginx/error.log | grep "limiting requests"
# 2024/06/01 12:03:41 [warn] 1234#0: *5678 limiting requests,
# excess: 10.400 by zone "api_limit", client: 203.0.113.9,
# server: api.example.com, request: "GET /v1/ping HTTP/1.1"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;One more thing worth knowing: IP-based limiting alone is trivially bypassed by botnets rotating across thousands of residential proxy IPs. If you're facing that kind of adversary, pair rate limiting with request fingerprinting — User-Agent plus TLS JA3 — or a CAPTCHA challenge. Rate limiting alone won't stop a determined attacker with a large IP pool; it just raises the cost of the attack.&lt;/p&gt;

&lt;h2&gt;Decision matrix&lt;/h2&gt;

&lt;p&gt;Here's how I map real situations to a choice, based on topology, granularity needs, budget, and team comfort with running Redis/Lua in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single nginx box fronting an internal or low-traffic public API&lt;/td&gt;
&lt;td&gt;Option A — native nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Startup MVP, no paying multi-tenant customers yet&lt;/td&gt;
&lt;td&gt;Option A now, revisit at scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need per-API-key or per-tenant quotas for billing&lt;/td&gt;
&lt;td&gt;Option B — Kong/OpenResty+Redis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-region API with paying tenants and SLA tiers&lt;/td&gt;
&lt;td&gt;Option B, with edge (Cloudflare) in front&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-volume public API, no budget for edge licensing&lt;/td&gt;
&lt;td&gt;Option A + fail2ban, tuned aggressively&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team has zero Redis/Lua experience and tight deadline&lt;/td&gt;
&lt;td&gt;Option A — don't add ops debt for a launch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most real setups I've built end up as a hybrid: native nginx handles coarse IP/connection limits at the edge of the config, and a gateway or app-layer service handles business-logic quotas — the "you get 10,000 API calls a month on the Starter plan" kind of enforcement that genuinely needs a database or Redis behind it. That split is honest about which layer is good at what.&lt;/p&gt;

&lt;h2&gt;My pick&lt;/h2&gt;

&lt;p&gt;I'll say it plainly: start with native nginx &lt;code&gt;limit_req&lt;/code&gt;/&lt;code&gt;limit_conn&lt;/code&gt; for anything under roughly 50 req/s baseline traffic and a single-node or simple LB setup. It's free, it's already running, and it handles 90% of the abuse patterns you'll actually see — scraper bursts, login brute-forcing, slow-POST DoS attempts. I stopped recommending Kong or Cloudflare Enterprise rate limiting as a first move after watching a client burn two weeks and a chunk of their infra budget solving a problem that &lt;code&gt;limit_req_zone&lt;/code&gt; and a whitelist rule would've fixed in an afternoon.&lt;/p&gt;

&lt;p&gt;My concrete setup on new projects: native nginx rate limiting as described above, paired with fail2ban parsing the error log — a filter watching for "limiting requests" that auto-bans repeat offenders at the firewall (iptables/nftables) level so they don't even reach nginx on their next attempt. That combination is free, low-latency, and shockingly effective. I only reach for Redis-backed OpenResty or a real gateway once there's an actual business requirement for per-API-key quotas across multiple nodes — not before. Nginx rate limiting isn't a stopgap you outgrow immediately; for most APIs, it's the right permanent answer. If you're building out the surrounding infra, our &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps notes and setups&lt;/a&gt; cover a lot of the adjacent pieces — logging, load balancer configs, and the fail2ban side of this exact stack. For the module internals, the &lt;a href="https://nginx.org/en/docs/http/ngx_http_limit_req_module.html" rel="noopener noreferrer"&gt;official ngx_http_limit_req_module docs&lt;/a&gt; and &lt;a href="https://developers.cloudflare.com/waf/rate-limiting-rules/" rel="noopener noreferrer"&gt;Cloudflare's rate limiting rules docs&lt;/a&gt; are worth reading before you commit either direction.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/docker/" rel="noopener noreferrer"&gt;More on securing and tuning reverse proxies in containerized setups&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;Security hardening patterns for public-facing APIs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/monitoring/" rel="noopener noreferrer"&gt;Monitoring and alerting setups to catch abuse before it escalates&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Docker BuildKit Cache Setup That Actually Speeds Up CI</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Tue, 11 Aug 2026 07:01:43 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/docker-buildkit-cache-setup-that-actually-speeds-up-ci-44di</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/docker-buildkit-cache-setup-that-actually-speeds-up-ci-44di</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/11/docker-buildkit-cache-setup-that-actually-speeds-up-ci" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Last month a client asked me why their "cached" Docker builds still took nine minutes on every single pull request. They had &lt;code&gt;--cache-from&lt;/code&gt; in their GitHub Actions workflow, a green checkmark, and a nagging suspicion something was off. Turned out their BuildKit cache had never actually hit once in three months — it was pulling a stale &lt;code&gt;:latest&lt;/code&gt; tag as cache source and silently falling back to a full rebuild every time. This is the single most common failure mode I see with Docker BuildKit cache CI setups, and it's almost always invisible until someone actually times the build.&lt;/p&gt;

&lt;h2&gt;What BuildKit Cache Actually Does Under the Hood&lt;/h2&gt;



&lt;p&gt;The legacy Docker build cache (pre-BuildKit) was dead simple: each instruction in the Dockerfile produced a layer, and that layer was reused if the instruction and its parent layer hadn't changed. It's content-addressable, but tightly coupled to instruction order. Change line 3, and everything below it invalidates — no exceptions.&lt;/p&gt;

&lt;p&gt;BuildKit changes the model. Cache is keyed by a digest computed from the actual inputs to a step: the base image digest, the build context checksum for anything copied in, and the resolved command. That's why two builds with identical Dockerfiles but different base image digests will miss cache even though the text is byte-identical — the input hash changed, not the instruction.&lt;/p&gt;

&lt;p&gt;There are three cache backends that matter in CI. Inline cache (&lt;code&gt;BUILDKIT_INLINE_CACHE=1&lt;/code&gt;) embeds cache metadata directly into the pushed image — this is deprecated since Buildx v0.10 in favor of registry cache. Registry cache (&lt;code&gt;--cache-to type=registry&lt;/code&gt;) pushes cache blobs to a separate manifest in your registry, independent of the final image tag. And local/GHA cache (&lt;code&gt;type=local&lt;/code&gt;, &lt;code&gt;type=gha&lt;/code&gt;) stores cache on disk or in GitHub's Actions cache service, capped at 10GB per repo.&lt;/p&gt;

&lt;p&gt;The key thing to internalize: a cache "hit" requires the build context checksum, base image digest, AND the instruction to all match what's in the cache manifest. Any &lt;code&gt;COPY&lt;/code&gt; or &lt;code&gt;ADD&lt;/code&gt; touching a changed file invalidates that layer and every layer after it. This is exactly why layer ordering matters so much, which is where most teams get it wrong. Docker's own &lt;a href="https://docs.docker.com/build/cache/" rel="noopener noreferrer"&gt;BuildKit cache documentation&lt;/a&gt; covers the backend types in more depth if you want the full matrix.&lt;/p&gt;

&lt;h2&gt;How People Use It Wrong&lt;/h2&gt;

&lt;p&gt;The first mistake I ran into with that client: pulling &lt;code&gt;--cache-from myimage:latest&lt;/code&gt; as the cache source. The problem is &lt;code&gt;:latest&lt;/code&gt; drifts — it's whatever the most recent successful build pushed, which might be from a completely different branch with a different dependency tree. BuildKit computes the cache key against what's actually in that manifest, and if it doesn't match, you get a silent miss with zero error message. No warning, just a slow build that looks "normal."&lt;/p&gt;

&lt;p&gt;The second mistake is structural: &lt;code&gt;COPY . .&lt;/code&gt; before &lt;code&gt;npm ci&lt;/code&gt; or &lt;code&gt;pip install&lt;/code&gt;. I've seen this in maybe 70% of Dockerfiles I've audited. Every commit — even a one-line README change — invalidates the entire dependency install layer, because the build context checksum for that COPY includes every file in the repo. You end up reinstalling node_modules or your virtualenv on every single push, even when the lockfile hasn't moved.&lt;/p&gt;

&lt;p&gt;The third mistake is relying purely on the CI runner's local disk. GitHub-hosted runners are ephemeral — each job gets a fresh VM. If you never export cache to a registry or the GHA backend, that "warm cache" from your last build simply doesn't exist anymore. I stopped trusting local-only caching on hosted runners after watching a team's "optimized" pipeline run cold for six months without anyone noticing, because nothing ever raised an error — the pipeline was just always slow. A related mistake: using &lt;code&gt;--cache-from&lt;/code&gt; without &lt;code&gt;--cache-to&lt;/code&gt;. You pull old cache but never write updates back, so the cache goes stale after the first real change and never refreshes.&lt;/p&gt;

&lt;h2&gt;The Correct Approach&lt;/h2&gt;

&lt;p&gt;The fix has two parts: Dockerfile structure and cache backend configuration. Structurally, order instructions from least volatile to most volatile — copy lockfiles first, install dependencies, then copy source last.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;FROM node:20-slim@sha256:abcd1234...  # pinned digest avoids silent cache invalidation

WORKDIR /app

# 1. Copy only lockfiles first — this layer stays cached until deps actually change
COPY package.json package-lock.json ./

# 2. Use cache mount for npm store — persists package cache across builds
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci --prefer-offline

# 3. Source code copied last — only invalidates this layer + below on every commit
COPY . .

RUN npm run build

# Expected CI log on cache hit:
# =&amp;gt; CACHED [2/5] COPY package.json package-lock.json ./
# =&amp;gt; CACHED [3/5] RUN npm ci --prefer-offline
# =&amp;gt; [4/5] COPY . .                                   0.4s
# =&amp;gt; [5/5] RUN npm run build                          22.1s&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Pinning the base image by digest matters more than people think — &lt;code&gt;node:20-slim&lt;/code&gt; can get re-tagged upstream without any change on your end, which silently busts your cache even though nothing in your repo changed.&lt;/p&gt;

&lt;p&gt;For the backend, registry cache is the most portable option since it works regardless of which runner picks up the job:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# .github/workflows/docker-build.yml
# CI pipeline demonstrating correct BuildKit cache setup with registry cache backend
name: docker-build

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          driver-opts: image=moby/buildkit:v0.13.2   # pin buildkit version explicitly

      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push with registry cache
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/org/app:${{ github.sha }}
          # cache-from pulls previous layers; cache-to writes updated ones
          cache-from: type=registry,ref=ghcr.io/org/app:buildcache
          cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max
          build-args: |
            BUILDKIT_INLINE_CACHE=0   # not needed, we use registry cache explicitly

      - name: Prune stale cache tags (weekly)
        if: github.event_name == 'schedule'
        run: |
          # crude example: delete cache manifests older than 14 days via crane
          crane ls ghcr.io/org/app | grep buildcache | while read tag; do
            echo "would prune $tag if older than 14d"
          done&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; if you see &lt;code&gt;ERROR: failed to solve: failed to load cache key&lt;/code&gt;, don't panic — it usually just means the cache ref doesn't exist yet (first run ever) or your registry auth expired mid-pull. It's not a corruption issue, just resolve auth or let the first build populate the ref.&lt;/p&gt;

&lt;h2&gt;Advanced Patterns&lt;/h2&gt;

&lt;p&gt;Once you've got the basics working, there are a few patterns worth adopting for teams running multiple services or monorepos. First, &lt;code&gt;mode=max&lt;/code&gt; vs &lt;code&gt;mode=min&lt;/code&gt;: &lt;code&gt;max&lt;/code&gt; exports every intermediate layer from every build stage, which is essential if you have multi-stage Dockerfiles with shared base stages across microservices — otherwise those intermediate stages never get cached for reuse elsewhere. &lt;code&gt;mode=min&lt;/code&gt; only exports the final stage, which is cheaper but useless if other targets depend on the same intermediate layer.&lt;/p&gt;

&lt;p&gt;Second, in monorepos, shard your cache keys by a hash of the Dockerfile plus the relevant lockfile, not just by repo name. Otherwise service A's cache pollutes service B's cache ref and you get cross-project false hits or unnecessary invalidation storms.&lt;/p&gt;

&lt;p&gt;Third, if you're building several targets in one CI job, &lt;code&gt;docker buildx bake&lt;/code&gt; with an HCL file lets you define &lt;code&gt;cache-from&lt;/code&gt;/&lt;code&gt;cache-to&lt;/code&gt; once and apply it across a matrix of targets, instead of copy-pasting the same flags into five separate build steps. It also avoids redundant registry pulls when multiple targets share a base stage. I wrote more about structuring shared build configs like this in our &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS&lt;/a&gt; archive if you want to see it applied to a full CI matrix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; multi-arch builds (&lt;code&gt;--platform linux/amd64,linux/arm64&lt;/code&gt;) need separate cache refs per platform, or your arm64 build won't be able to reuse amd64 cache layers at all — I've seen teams assume one cache ref covers both architectures and wonder why arm64 builds are always cold.&lt;/p&gt;

&lt;h2&gt;Performance Notes&lt;/h2&gt;

&lt;p&gt;Numbers matter more than theory here. A cold CI runner with zero cache backend typically takes 8-12 minutes for a standard Node or Python app build. With registry cache configured correctly and dependency layers ordered properly, warm builds routinely drop to 30-90 seconds. That's the real payoff — but it's not free.&lt;/p&gt;

&lt;p&gt;Registry cache push/pull adds latency per layer, usually 5-30 seconds depending on registry throughput and image size. It's a net win only if you're building frequently enough that the amortized savings outweigh that overhead — for a repo pushed a few times a day, it's an easy yes.&lt;/p&gt;

&lt;p&gt;Storage cost is the tradeoff nobody budgets for. &lt;code&gt;mode=max&lt;/code&gt; exports every intermediate layer, which can balloon registry storage to 2-3x your actual image size. On ECR, GCR, or ACR, that's billed per GB-month, and unbounded caches across dozens of feature branches will quietly grow your bill. Set a lifecycle rule to expire &lt;code&gt;*:buildcache&lt;/code&gt; tags older than 14 days — it's a five-minute fix that prevents a very avoidable line item.&lt;/p&gt;

&lt;p&gt;One more thing worth flagging as a security note, not just performance: cache blobs pushed to a shared or public registry can leak build secrets or leftover file contents if you're not using &lt;code&gt;--mount=type=secret&lt;/code&gt;. Cache layers aren't scrubbed of env vars or files copied in before a later cleanup &lt;code&gt;RUN&lt;/code&gt; — if a secret touched the filesystem at any point, it can persist in the cache manifest even after you delete it in a later layer. I've had to explain this one twice to teams who assumed a final &lt;code&gt;RUN rm -rf&lt;/code&gt; was enough. Check the &lt;a href="https://docs.docker.com/build/building/secrets/" rel="noopener noreferrer"&gt;Docker build secrets docs&lt;/a&gt; before you bake anything sensitive into a cached stage.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/docker/" rel="noopener noreferrer"&gt;More Docker build and image optimization patterns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/ci-cd/" rel="noopener noreferrer"&gt;CI/CD pipeline design and GitHub Actions deep dives&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;Build secret handling and registry security practices&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>RDS Backup Restore Testing: 7 Checks Before You Trust It</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:01:42 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/rds-backup-restore-testing-7-checks-before-you-trust-it-oam</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/rds-backup-restore-testing-7-checks-before-you-trust-it-oam</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/10/rds-backup-restore-testing-7-checks-before-you-trust-it" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Your RDS backups show "available" every night in the console. Green checkmark, no alarms, everyone's happy. But have you actually restored one of those snapshots and queried the data inside it? I ask because we didn't — for almost a year — until a corrupted binlog turned our "reliable" backup strategy into a three-hour incident call. RDS backup restore testing isn't optional infrastructure hygiene, it's the only way to know your recovery plan actually works.&lt;/p&gt;

&lt;h2&gt;Automate the Restore, Don't Trust the "Available" Status&lt;/h2&gt;



&lt;p&gt;An RDS snapshot marked "available" only confirms the backup &lt;em&gt;process&lt;/em&gt; finished without error — it says nothing about whether the data inside is usable. I've seen teams treat a green CloudWatch backup alarm as proof of recoverability, then discover during a real incident that the snapshot restores into an instance with half the expected rows. The fix is boring but effective: schedule a recurring EventBridge rule that triggers a Lambda to run &lt;code&gt;restore-db-instance-from-db-snapshot&lt;/code&gt; weekly or monthly, automatically, without a human remembering to do it.&lt;/p&gt;

&lt;p&gt;This isn't about paranoia — it's about closing the gap between "backup completed" and "backup is recoverable." Those are two very different claims, and RDS only ever promises you the first one.&lt;/p&gt;

&lt;h2&gt;Restore Into an Isolated Network, Not Your Production VPC&lt;/h2&gt;

&lt;p&gt;Restoring a snapshot into the same VPC as production is asking for CIDR and security-group conflicts, and in the worst case, test traffic touching real systems. We keep a dedicated "restore-testing" subnet group with zero routes to prod app servers, and tag every restored instance with &lt;code&gt;Purpose=restore-test&lt;/code&gt; plus an expiry timestamp so cleanup automation can find it later.&lt;/p&gt;

&lt;p&gt;Watch out: if you forget to attach a security group during the restore call, the instance comes up "available" but is completely unreachable — your validation script will just hang or fail silently with a connection timeout, and it's easy to mistake that for a data problem instead of a networking one.&lt;/p&gt;

&lt;h2&gt;Verify Point-in-Time Recovery, Not Just Snapshot Age&lt;/h2&gt;

&lt;p&gt;Snapshots are simple — PITR is not. Point-in-time recovery depends on continuous transaction log shipping, and that pipeline fails silently far more often than a nightly snapshot job does. Before you assume PITR can restore to "now," check &lt;code&gt;LatestRestorableTime&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ aws rds describe-db-instances \
    --db-instance-identifier prod-orders-db \
    --query 'DBInstances[0].[LatestRestorableTime,EarliestRestorableTime]' \
    --output table
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Output looks like this:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;-----------------------------------------------
|            DescribeDBInstances              |
+-----------------------------+---------------+
|  2024-05-14T09:42:17.000Z  | 2024-04-09T00:00:00.000Z |
+-----------------------------+---------------+

# Gotcha: if LatestRestorableTime is more than 5-10 minutes behind current
# time, binlog retention or transaction log backup may be misconfigured —
# investigate before trusting PITR for RTO planning.
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For MySQL/MariaDB, PITR quietly depends on binlog retention configured via &lt;code&gt;mysql.rds_set_configuration('binlog retention hours', 168)&lt;/code&gt;. If that's not set, your recoverable window shrinks without any alarm firing. And don't confuse Aurora's "backtrack" feature with PITR — backtrack tops out at 72 hours and only works on Aurora MySQL, it's not a substitute for transaction-log-based recovery.&lt;/p&gt;

&lt;h2&gt;Validate Data Integrity After Restore, Not Just Connectivity&lt;/h2&gt;

&lt;p&gt;A restored instance that accepts connections isn't the same as a restored instance with intact data. I've watched a "successful" restore return a working psql prompt against a table that was silently truncated three days before the incident — connectivity checks alone would've called that a pass. Run row-count comparisons and checksum queries against known baselines: &lt;code&gt;CHECKSUM TABLE&lt;/code&gt; for MySQL, &lt;code&gt;pg_checksums --check&lt;/code&gt; for Postgres (note: Postgres checksums have to be enabled at &lt;code&gt;initdb&lt;/code&gt; time, you can't bolt them on retroactively).&lt;/p&gt;

&lt;p&gt;Here's a script we run after every scheduled restore test — it restores, waits, checks row counts against a known minimum, then tears itself down:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/usr/bin/env bash
# restore-test.sh - automate an RDS snapshot restore + basic validation
set -euo pipefail

SNAPSHOT_ID="prod-db-snapshot-2024-05-01"
TEST_INSTANCE_ID="restore-test-$(date +%s)"
SUBNET_GROUP="restore-testing-subnet-group"
SECURITY_GROUP="sg-0123456789abcdef0"

echo "Starting restore of $SNAPSHOT_ID into $TEST_INSTANCE_ID..."

aws rds restore-db-instance-from-db-snapshot \
  --db-instance-identifier "$TEST_INSTANCE_ID" \
  --db-snapshot-identifier "$SNAPSHOT_ID" \
  --db-subnet-group-name "$SUBNET_GROUP" \
  --vpc-security-group-ids "$SECURITY_GROUP" \
  --no-publicly-accessible \
  --tags Key=Purpose,Value=restore-test Key=ExpiryDate,Value="$(date -d '+1 day' +%F)"

echo "Waiting for instance to become available..."
aws rds wait db-instance-available --db-instance-identifier "$TEST_INSTANCE_ID"

ENDPOINT=$(aws rds describe-db-instances \
  --db-instance-identifier "$TEST_INSTANCE_ID" \
  --query 'DBInstances[0].Endpoint.Address' --output text)

echo "Instance available at $ENDPOINT. Running validation checks..."

# Basic connectivity + row count check (Postgres example)
ROW_COUNT=$(PGPASSWORD="$DB_PASSWORD" psql -h "$ENDPOINT" -U app_readonly -d appdb -t -c \
  "SELECT count(*) FROM orders;")

EXPECTED_MIN_ROWS=100000

if [ "$ROW_COUNT" -lt "$EXPECTED_MIN_ROWS" ]; then
  echo "FAIL: row count $ROW_COUNT below expected minimum $EXPECTED_MIN_ROWS"
  exit 1
fi

echo "PASS: row count check ($ROW_COUNT rows)"

# Cleanup - delete test instance after validation
aws rds delete-db-instance \
  --db-instance-identifier "$TEST_INSTANCE_ID" \
  --skip-final-snapshot

echo "Restore test complete. Test instance deleted."
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Wire this into your CI runner or a Lambda triggered by EventBridge, and you get an automated proof-of-recoverability report instead of a green dashboard icon that means nothing.&lt;/p&gt;

&lt;h2&gt;Test Cross-Region and Cross-Account Snapshot Copies for DR&lt;/h2&gt;

&lt;p&gt;If your disaster recovery plan only tests same-region restores, it isn't really tested — a regional outage is exactly the scenario your DR plan exists for, and that's the one path most teams never exercise. Cross-region snapshot copies require re-encryption with a KMS key that exists in the destination region, and this trips people up constantly because the source key ARN simply doesn't resolve there.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;aws rds copy-db-snapshot \
  --source-region us-east-1 \
  --source-db-snapshot-identifier arn:aws:rds:us-east-1:111122223333:snapshot:prod-orders-2024-05-01 \
  --target-db-snapshot-identifier prod-orders-dr-copy \
  --kms-key-id arn:aws:kms:us-west-2:111122223333:key/abcd-1234-efgh-5678
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Confirm the KMS key ARN is region-local &lt;em&gt;before&lt;/em&gt; automating this, and log copy duration — a 500GB+ snapshot can take 30-60 minutes, which directly eats into your RTO budget if you haven't accounted for it. See the &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_CopySnapshot.html" rel="noopener noreferrer"&gt;AWS RDS snapshot copy docs&lt;/a&gt; for the full parameter list and region-specific caveats.&lt;/p&gt;

&lt;h2&gt;Watch Snapshot Storage Costs During Test Cycles&lt;/h2&gt;

&lt;p&gt;Automated RDS backups cap out at 35 days retention and expire on their own. Manual snapshots don't — they sit there billed per GB-month indefinitely until someone deletes them, and if you're running weekly restore tests without cleanup, that bill creeps up quietly for months before anyone notices. Storage cost tracks the actual used size within the snapshot, not the allocated instance storage, but frequent test cycles still add up fast on multi-hundred-GB databases.&lt;/p&gt;

&lt;p&gt;Automate deletion of test-restore instances and snapshots with a Lambda that scans for the &lt;code&gt;Purpose=restore-test&lt;/code&gt; tag and an expired &lt;code&gt;ExpiryDate&lt;/code&gt;, then deletes both the instance and any snapshots it spawned. I stopped relying on manual cleanup after finding four forgotten restore-test instances running for two months — that's real money for zero value.&lt;/p&gt;

&lt;h2&gt;Lock Down IAM and Encryption for Restore Operations&lt;/h2&gt;

&lt;p&gt;Restore permissions are more powerful than most teams realize, and they're frequently over-provisioned at the account level instead of scoped per environment. Restrict &lt;code&gt;rds:RestoreDBInstanceFromDBSnapshot&lt;/code&gt; and &lt;code&gt;rds:CopyDBSnapshot&lt;/code&gt; to specific roles using IAM condition keys — anyone with broad RDS access can otherwise spin up a full copy of production data in a subnet you never intended.&lt;/p&gt;

&lt;p&gt;Two gotchas worth flagging explicitly. First, KMS key policies must grant &lt;code&gt;kms:CreateGrant&lt;/code&gt; to the restore role or cross-account/cross-region restores fail with &lt;code&gt;KMSKeyNotAccessibleFault&lt;/code&gt; — a confusing error that has nothing to do with the RDS permissions themselves. Second, restoring always creates a brand-new instance (there's no in-place restore in RDS), and storage class or public accessibility settings can silently default differently than the source — always pass &lt;code&gt;--no-publicly-accessible&lt;/code&gt; explicitly rather than trusting the default. Check the &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_RestoreFromSnapshot.html" rel="noopener noreferrer"&gt;RDS restore documentation&lt;/a&gt; before scripting this into your pipeline, and pin your tooling — AWS CLI v2.15+ and Terraform's aws provider ~&amp;gt; 5.40 both have relevant fixes for snapshot copy support.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's just the difference between a backup strategy that looks good on a dashboard and one that survives an actual incident. If your restore automation lives inside a broader Terraform or Ansible pipeline, it's worth reviewing how the rest of your &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;infrastructure automation&lt;/a&gt; handles secrets and state before you wire restore-testing into CI.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/aws/" rel="noopener noreferrer"&gt;More AWS automation patterns for Lambda, EventBridge, and RDS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Terraform state and secrets management done right&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/databases/" rel="noopener noreferrer"&gt;Database operations, backups, and integrity checks in production&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>EventBridge Retry Policy vs SQS DLQ: The Right Call in Production</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sun, 09 Aug 2026 07:01:58 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/eventbridge-retry-policy-vs-sqs-dlq-the-right-call-in-production-377c</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/eventbridge-retry-policy-vs-sqs-dlq-the-right-call-in-production-377c</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/09/eventbridge-retry-policy-vs-sqs-dlq-the-right-call-in-production" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Your EventBridge target has been silently dropping failed events into a DLQ with zero write permissions — for three weeks — and nobody noticed until a customer asked where their order went. We pulled up the CloudWatch metrics, saw &lt;code&gt;FailedInvocations&lt;/code&gt; climbing, and then discovered the DLQ had exactly zero messages in it. Not because nothing failed. Because the resource policy on the queue never granted &lt;code&gt;events.amazonaws.com&lt;/code&gt; permission to write to it. That's the moment I stopped treating EventBridge retry policy and DLQ setup as a checkbox and started treating it as an actual design decision.&lt;/p&gt;

&lt;h2&gt;When you face this choice&lt;/h2&gt;



&lt;p&gt;This comes up the moment you wire an EventBridge rule to a target that isn't 100% reliable — a Lambda that occasionally times out, a Step Functions execution that throttles under load, or an API destination hitting a flaky third-party endpoint. You need a resilience story before this ships to production, and you have two real paths: lean entirely on EventBridge's built-in per-target &lt;code&gt;RetryPolicy&lt;/code&gt; and &lt;code&gt;DeadLetterConfig&lt;/code&gt;, or put an SQS queue in front of your actual consumer and own the retry/backoff/redrive logic yourself.&lt;/p&gt;

&lt;p&gt;Here's the part that trips people up: this decision is made &lt;strong&gt;per target&lt;/strong&gt;, not per bus and not per rule. I've seen teams configure a beautiful DLQ on their first target, ship a second target on the same rule three sprints later, and assume the DLQ config "just applies." It doesn't. Every target gets its own &lt;code&gt;DeadLetterConfig&lt;/code&gt;, and if you forget it, that target fails open — into nothing, with no alert, no trace, no evidence anything went wrong until someone downstream notices missing data.&lt;/p&gt;

&lt;p&gt;So before you write a single line of Terraform, decide: is native retry + DLQ enough for this target, or do you need the control that only a buffer queue gives you? Don't default to whichever pattern you copy-pasted from the last project. They solve different problems.&lt;/p&gt;

&lt;h2&gt;Option A: Native EventBridge RetryPolicy + DLQ (pros/cons)&lt;/h2&gt;

&lt;p&gt;This is the zero-infrastructure option. You set &lt;code&gt;maximum_retry_attempts&lt;/code&gt; and &lt;code&gt;maximum_event_age_in_seconds&lt;/code&gt; on the target, point &lt;code&gt;DeadLetterConfig.Arn&lt;/code&gt; at an SQS queue, and EventBridge handles retries with its own exponential backoff. No Lambda glue, no extra queue to monitor for the happy path, and it works the same whether your target is Lambda, Step Functions, or an API destination.&lt;/p&gt;

&lt;p&gt;The catch: those two knobs are the &lt;em&gt;only&lt;/em&gt; knobs. You don't control the shape of the backoff curve — only the ceiling. Default &lt;code&gt;MaximumRetryAttempts&lt;/code&gt; is 185, and default &lt;code&gt;MaximumEventAgeInSeconds&lt;/code&gt; is 86400 (24 hours). Most teams never touch these defaults and don't realize what that means in practice: EventBridge will keep retrying a failing target for a full day, and every one of those retries is a billed invocation downstream. During an outage, a Lambda hammered by 185 retry attempts across dozens of events can spike your invocation cost 100x almost overnight.&lt;/p&gt;

&lt;p&gt;Watch out: setting &lt;code&gt;MaximumRetryAttempts: 0&lt;/code&gt; alone does not give you immediate DLQ routing. You still need to explicitly set &lt;code&gt;MaximumEventAgeInSeconds&lt;/code&gt;, or the event stays "alive" for the default 24 hours even with zero retry attempts configured — a confusing half-state that looks like a bug but is documented behavior.&lt;/p&gt;

&lt;p&gt;Debugging is the other weak point. The message that lands in your DLQ is the raw event, not an error trace. You get no built-in "why did this fail" — you're cross-referencing CloudWatch Logs and metrics like &lt;code&gt;TargetErrorCount&lt;/code&gt; and &lt;code&gt;DeadLetterInvocations&lt;/code&gt; manually, event by event.&lt;/p&gt;

&lt;h2&gt;Option B: SQS buffer queue as the real target (pros/cons)&lt;/h2&gt;

&lt;p&gt;Here you don't target the Lambda or Step Function directly — you target an SQS queue, and your actual consumer reads from that queue. This buys you real control: batching behavior, visibility timeout tuning, and backoff shaped by your Lambda's SQS trigger config, not EventBridge's fixed curve. Redrive is native and supported — set &lt;code&gt;RedrivePolicy.maxReceiveCount&lt;/code&gt; and AWS will actually let you redrive messages back out of the DLQ using &lt;code&gt;aws sqs start-message-move-task&lt;/code&gt;, GA since late 2023.&lt;/p&gt;

&lt;p&gt;It also decouples delivery from processing. EventBridge-to-SQS delivery is near-instant and reliable, so any failure is isolated to the consumer side — easier to reason about, easier to load-test independently of the bus.&lt;/p&gt;

&lt;p&gt;The cost is real, though. You're now provisioning, monitoring, and paying for an extra moving part. You own idempotency — SQS is at-least-once delivery, and your own retry logic on top of that can double-process events if your handler isn't idempotent. There's also a subtle latency hit on the happy path since every event now makes an extra hop.&lt;/p&gt;

&lt;p&gt;Gotcha: don't assume SQS-as-target gives you ordering. Standard queues don't guarantee it. Switching to FIFO fixes ordering but caps you at 300 msg/s without batching — a real bottleneck if your bus expects high fan-out volume. I've watched a team switch to FIFO "for safety" and immediately create a backlog during a traffic spike that the standard queue would have absorbed fine.&lt;/p&gt;

&lt;h2&gt;Decision matrix&lt;/h2&gt;

&lt;p&gt;Use this to skip the re-reading and go straight to a decision.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Row                          Native RetryPolicy+DLQ        SQS Buffer Queue
Retry control granularity    Low — ceiling only            High — full backoff/visibility control
Ease of setup                High — one-liner target block Medium — extra queue + policy + trigger
Cost overhead                Low, but retry storms spike   Moderate, predictable
                              downstream invocation cost
Observability quality        Poor — raw event, no error    Good — CloudWatch + queue depth +
                              context in DLQ                 redrive task status
Replay/redrive capability     Manual only, no native tool   Native via start-message-move-task
Latency impact                None                          Small added hop, usually milliseconds
Best-fit target               Lambda w/ idempotent handler  Step Functions, batch consumers,
                                                              high-throughput fan-out
Team size / maturity          Small teams, low on-call      Needs someone who can own redrive
                              overhead — good default        runbooks and queue monitoring
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;That last row matters more than people admit. I've seen small teams adopt the SQS buffer pattern because it's "more correct," then quietly regret it six months later when nobody remembers how the redrive script works and the DLQ has 40,000 stale messages nobody wants to touch.&lt;/p&gt;

&lt;h2&gt;My pick&lt;/h2&gt;

&lt;p&gt;I default to native RetryPolicy + DLQ for Lambda targets with idempotent handlers and non-critical volume. It's good enough for roughly 80% of the pipelines I've built, and I stopped over-engineering this after watching a team spend two sprints building custom SQS redrive tooling for a target that failed maybe twice a month. Not every event pipeline needs a queue in front of it.&lt;/p&gt;

&lt;p&gt;I switch to the SQS buffer pattern only when I need batch processing, something close to ordering guarantees, or a redrive workflow that an on-call engineer can actually run at 2am without writing custom Lambda glue first. If your target is Step Functions, remember EventBridge's RetryPolicy only covers its own delivery attempt — internal state machine failures need their own &lt;code&gt;Retry&lt;/code&gt;/&lt;code&gt;Catch&lt;/code&gt; blocks regardless of which pattern you pick.&lt;/p&gt;

&lt;p&gt;My non-negotiable stance: never ship an EventBridge target to production without some DLQ configured, and never trust that it's working without checking the SQS resource policy explicitly. The number of teams that discover silently-dropped events during an incident postmortem — not before — is the entire reason I write posts like this. We've covered similar production gotchas in our &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps notes on kuryzhev.cloud&lt;/a&gt; if you want more of these before-it-bites-you writeups.&lt;/p&gt;

&lt;p&gt;Here's the Terraform for the native approach, DLQ policy included — this is the one-liner people forget to pair with an actual resource policy:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# EventBridge rule targeting Lambda, with native RetryPolicy + DeadLetterConfig (Option A)

resource "aws_sqs_queue" "eventbridge_dlq" {
  name                      = "orders-eventbridge-dlq"
  message_retention_seconds = 1209600  # 14 days — max retention, buys time to investigate
  kms_master_key_id         = "alias/aws/sqs"  # encrypt at rest, security baseline
}

# Explicit resource policy — without this, DLQ writes fail silently
resource "aws_sqs_queue_policy" "dlq_policy" {
  queue_url = aws_sqs_queue.eventbridge_dlq.id
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Sid       = "AllowEventBridgeSend",
      Effect    = "Allow",
      Principal = { Service = "events.amazonaws.com" },
      Action    = "sqs:SendMessage",
      Resource  = aws_sqs_queue.eventbridge_dlq.arn,
      Condition = {
        ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.orders_created.arn }
      }
    }]
  })
}

resource "aws_cloudwatch_event_rule" "orders_created" {
  name          = "orders-created"
  event_pattern = jsonencode({ source = ["orders.service"] })
}

resource "aws_cloudwatch_event_target" "process_order" {
  rule      = aws_cloudwatch_event_rule.orders_created.name
  arn       = aws_lambda_function.process_order.arn
  target_id = "process-order-lambda"

  retry_policy {
    maximum_retry_attempts       = 3          # cap retries — don't let defaults hammer for 24h
    maximum_event_age_in_seconds = 3600       # give up after 1h, not the 86400s default
  }

  dead_letter_config {
    arn = aws_sqs_queue.eventbridge_dlq.arn   # this is per-TARGET, not per-rule
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And when messages do end up in the DLQ, here's how to inspect and redrive them without writing a custom Lambda — this uses the native SQS message-move task:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# Redriving a stuck DLQ back to the consumer queue (Option B pattern),
# using the native SQS message-move task — no custom Lambda needed.

# 1. Inspect what's sitting in the DLQ before redriving blindly
aws sqs get-queue-attributes \
  --queue-url "$DLQ_URL" \
  --attribute-names ApproximateNumberOfMessages

# Example output:
# {
#   "Attributes": {
#     "ApproximateNumberOfMessages": "47"
#   }
# }

# 2. Start the move task — requires awscli v2 &amp;gt;= 2.13.0 / botocore &amp;gt;= 1.31.0
aws sqs start-message-move-task \
  --source-arn "$DLQ_ARN" \
  --destination-arn "$MAIN_QUEUE_ARN" \
  --max-number-of-messages-per-second 5   # throttle to avoid re-triggering the original failure spike

# 3. Track progress of the redrive
aws sqs list-message-move-tasks --source-arn "$DLQ_ARN"

# Common gotcha: if the original failure was a bad payload (not transient),
# redriving just replays the same crash — check CloudWatch Logs error signature
# on a sample message BEFORE redriving 47 messages back into production.
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Set CloudWatch alarms on &lt;code&gt;InvocationsSentToDlq &amp;gt; 0&lt;/code&gt; no matter which option you pick — EventBridge will never alert you on its own, and the whole point of the DLQ is to catch failures you'd otherwise never see. For the exact semantics of retry policies and dead-letter configs, the &lt;a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-rule-dlq.html" rel="noopener noreferrer"&gt;AWS EventBridge DLQ documentation&lt;/a&gt; and the &lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configuring-redrive-allow-policy.html" rel="noopener noreferrer"&gt;SQS redrive policy docs&lt;/a&gt; are worth bookmarking before you ship either pattern.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/aws/" rel="noopener noreferrer"&gt;More AWS automation and Lambda patterns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Terraform patterns for AWS resources and state management&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/monitoring/" rel="noopener noreferrer"&gt;CloudWatch alerting setups for production failure detection&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>GitHub Actions Reusable Workflow Environment Protection Checklist</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sat, 08 Aug 2026 07:01:58 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/github-actions-reusable-workflow-environment-protection-checklist-1m7i</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/github-actions-reusable-workflow-environment-protection-checklist-1m7i</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/08/github-actions-reusable-workflow-environment-protection-checklist" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;Why this checklist&lt;/h2&gt;



&lt;p&gt;Last quarter one of our platform teams shipped a shiny &lt;code&gt;.github/workflows/reusable-deploy.yml&lt;/code&gt; from a central repo. Every service team switched their pipelines over to call it via &lt;code&gt;workflow_call&lt;/code&gt; within a week. It looked great — one workflow, consistent deploy logic, less duplicated YAML across forty repos. Then someone noticed a "protected" production deploy had gone out with no approvals at all, no wait timer, nothing. The environment protection rules reusable workflows are supposed to enforce simply weren't there.&lt;/p&gt;

&lt;p&gt;The root cause is something GitHub doesn't explain clearly anywhere obvious: protection rules apply to the &lt;em&gt;job&lt;/em&gt; that references an environment, evaluated against the &lt;strong&gt;caller repo's&lt;/strong&gt; settings — not the repo that hosts the reusable workflow. If the consumer repo never created an environment named exactly &lt;code&gt;production&lt;/code&gt;, GitHub just auto-creates one on first run, with zero reviewers, zero branch policy, zero wait timer. It doesn't error. It doesn't warn. It deploys.&lt;/p&gt;

&lt;p&gt;This causes two failure classes we've seen repeatedly. First, secrets over-exposure — teams reach for &lt;code&gt;secrets: inherit&lt;/code&gt; because it's convenient, and suddenly the reusable workflow has access to every secret the caller repo owns, including ones it never needed. Second, silent unprotected deploys — an environment name typo (&lt;code&gt;Production&lt;/code&gt; vs &lt;code&gt;production&lt;/code&gt;) creates a brand new, completely open environment instead of failing the run. We built this checklist after auditing 40+ repos and finding both problems live in prod.&lt;/p&gt;

&lt;h2&gt;The checklist (numbered)&lt;/h2&gt;

&lt;p&gt;Run through this before you trust any reusable workflow + environment combo touching production.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Environment exists in the calling repo, not just the source repo.&lt;/strong&gt; Check Settings → Environments in the consumer repo itself. If it's missing, GitHub will silently create an unprotected one on the first run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment name is case-exact.&lt;/strong&gt; &lt;code&gt;environment: Production&lt;/code&gt; in the workflow input vs &lt;code&gt;production&lt;/code&gt; in repo settings creates two different environments. Diff them character by character if you're paranoid — we now are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions declared explicitly per job.&lt;/strong&gt; The top-level &lt;code&gt;permissions:&lt;/code&gt; block from the caller does not flow into the reusable workflow's jobs. Declare &lt;code&gt;contents: read&lt;/code&gt;, &lt;code&gt;id-token: write&lt;/code&gt;, etc. inside the reusable workflow itself, especially for OIDC-based cloud auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;secrets: inherit&lt;/code&gt;.&lt;/strong&gt; Replace it with an explicit mapping like &lt;code&gt;secrets: { DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }} }&lt;/code&gt;. Inherit passes everything, used or not — a bigger blast radius than most teams realize until an incident review forces them to enumerate it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Required reviewers list is populated and sane.&lt;/strong&gt; Max is 6 users/teams per environment — if you're at the cap, that's usually a sign the team is too big for a single gate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait timer value is intentional.&lt;/strong&gt; Valid range is 0–43200 minutes (30 days). We've found wait timers left at defaults nobody remembers setting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment branch policy isn't "all branches."&lt;/strong&gt; For prod, it should be "protected branches only" or a tight glob like &lt;code&gt;release/*&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outputs are declared under &lt;code&gt;workflow_call.outputs&lt;/code&gt;.&lt;/strong&gt; Referencing an undeclared output returns an empty string with no error — a silent failure that's brutal to debug at 2am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency group is set explicitly inside the reusable workflow.&lt;/strong&gt; It is not inherited from the caller. Skip this and you can get two parallel deploys hitting the same environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nesting depth ≤4, total reusable calls ≤20 per run.&lt;/strong&gt; Exceed either and you get "The workflow is not valid... too many levels of nested workflows" — usually discovered mid-incident, not in review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted runner labels passed as inputs.&lt;/strong&gt; &lt;code&gt;runs-on&lt;/code&gt; can't be overridden directly by the caller; interpolate it via &lt;code&gt;${{ inputs.runner_label }}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's a working example that gets most of this right — pin the version, don't call &lt;code&gt;@main&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# .github/workflows/reusable-deploy.yml (lives in platform repo, called by consumers)
on:
  workflow_call:
    inputs:
      env_name:
        required: true
        type: string
      runner_label:
        required: false
        type: string
        default: ubuntu-latest
    secrets:
      DEPLOY_TOKEN:          # must be explicitly declared to receive it via non-inherit calls
        required: true
    outputs:
      deployment_url:
        description: "URL of the deployed environment"
        value: ${{ jobs.deploy.outputs.url }}

permissions:
  contents: read             # explicit — do NOT rely on caller's permissions being inherited

concurrency:
  group: deploy-${{ inputs.env_name }}   # prevents parallel deploys to same environment
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ${{ inputs.runner_label }}
    environment: ${{ inputs.env_name }}   # protection rules evaluated against CALLER repo's env
    outputs:
      url: ${{ steps.set_url.outputs.url }}
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          echo "Deploying to ${{ inputs.env_name }} on ${{ inputs.runner_label }}"
          # actual deploy command here

      - id: set_url
        run: echo "url=https://${{ inputs.env_name }}.example.com" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"

---
# .github/workflows/ci.yml (consumer repo — this is where the environment must exist)
on: [push]

jobs:
  call-deploy:
    uses: my-org/platform-workflows/.github/workflows/reusable-deploy.yml@v1.4.0  # pin, don't use @main
    with:
      env_name: production        # must exactly match an environment created in THIS repo's settings
      runner_label: ubuntu-latest
    secrets:
      DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}   # explicit, not `inherit`
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Commonly missed items&lt;/h2&gt;

&lt;p&gt;These pass a five-minute review but bite you during an actual incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; fork-originated pull requests. If your workflow triggers on &lt;code&gt;pull_request&lt;/code&gt; from a fork, GitHub withholds environment secrets entirely — regardless of approvals — even if the job references a protected environment. We spent an hour once debugging a "missing secret" error that turned out to be expected fork behavior. If you need this to work, switch to &lt;code&gt;pull_request_target&lt;/code&gt; or split the deploy step into a separate &lt;code&gt;workflow_run&lt;/code&gt; trigger. See the &lt;a href="https://docs.github.com/en/actions/using-workflows/reusing-workflows" rel="noopener noreferrer"&gt;GitHub Actions docs on reusable workflows&lt;/a&gt; for the exact secret-passing rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; cross-org or cross-repo reusable workflow calls. Teams often check the environments configured in the repo hosting the reusable workflow (the platform repo) and assume that's what's enforced. It isn't. GitHub evaluates protection against the caller repo's environment of the same name. I've seen teams audit the wrong repo entirely and walk away confident nothing was wrong.&lt;/p&gt;

&lt;p&gt;Approval fatigue is the third trap, and it's a human one, not a config one. When workflow A calls workflow B, and B references an environment, the approval UI at two levels of nesting doesn't clearly surface which environment is actually being targeted. Reviewers click "approve" without registering it's &lt;code&gt;prod&lt;/code&gt;. Fix this by naming jobs explicitly — &lt;code&gt;name: Deploy to ${{ inputs.env_name }}&lt;/code&gt; — so the environment shows up in the run summary, not just buried in YAML.&lt;/p&gt;

&lt;p&gt;One thing worth knowing: jobs paused waiting on approval don't burn billable Actions minutes. So there's no cost excuse for skipping gates — it's effectively free insurance against exactly this class of incident.&lt;/p&gt;

&lt;h2&gt;Automation ideas&lt;/h2&gt;

&lt;p&gt;Manual review doesn't scale past a handful of repos, so we automated most of this checklist.&lt;/p&gt;

&lt;p&gt;First, codify environments with Terraform instead of clicking through Settings → Environments. The &lt;code&gt;github_repository_environment&lt;/code&gt; and &lt;code&gt;github_repository_environment_deployment_policy&lt;/code&gt; resources make reviewers, wait timers, and branch policy diffable in a PR — no more "who changed the reviewer list and when."&lt;/p&gt;

&lt;p&gt;Second, run a scheduled audit across the org. This catches drift between what Terraform says should exist and what's actually configured (someone always clicks around in the UI eventually):&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# audit-environments.sh — checks all repos in an org for weak/missing protection rules
ORG="my-org"

for repo in $(gh repo list "$ORG" --limit 200 --json name -q '.[].name'); do
  envs=$(gh api "repos/$ORG/$repo/environments" --jq '.environments[].name' 2&amp;gt;/dev/null)

  for env in $envs; do
    reviewers=$(gh api "repos/$ORG/$repo/environments/$env" \
      --jq '.protection_rules[] | select(.type=="required_reviewers") | .reviewers | length')

    if [ -z "$reviewers" ] || [ "$reviewers" -eq 0 ]; then
      echo "⚠️  $repo/$env has NO required reviewers configured"
    fi
  done
done

# Example output:
# ⚠️  billing-service/production has NO required reviewers configured
# ⚠️  legacy-cron/prod has NO required reviewers configured
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Third, gate this at PR time, not after merge. A custom &lt;code&gt;actionlint&lt;/code&gt; plugin or a small Python script can fail CI if a reusable workflow call uses &lt;code&gt;secrets: inherit&lt;/code&gt; or a called workflow omits an explicit &lt;code&gt;permissions:&lt;/code&gt; block. We wired this into our pre-merge checks alongside the broader &lt;a href="https://kuryzhev.cloud/category/ci-cd/" rel="noopener noreferrer"&gt;CI/CD checklist we use for release gates&lt;/a&gt;, and it's caught at least three risky PRs before they hit main.&lt;/p&gt;

&lt;p&gt;None of this is exotic tooling. It's Terraform, a bash loop, and a linter rule — the same pattern I'd apply to any environment protection rules reusable workflows setup where "we'll just review it manually" is the thing that eventually fails at 2am.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/ci-cd/" rel="noopener noreferrer"&gt;More CI/CD checklists covering quality gates and rollback paths&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/github/" rel="noopener noreferrer"&gt;GitHub Actions patterns and workflow troubleshooting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;Security pitfalls in pipeline secrets and access scoping&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Jenkins Shared Library Structure: vars/ vs src/ for Pipelines</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Fri, 07 Aug 2026 07:02:09 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/jenkins-shared-library-structure-vars-vs-src-for-pipelines-2m8p</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/jenkins-shared-library-structure-vars-vs-src-for-pipelines-2m8p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/07/jenkins-shared-library-structure-vars-vs-src-for-pipelines" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;When you face this choice&lt;/h2&gt;



&lt;p&gt;The Jenkins shared library structure question usually shows up the moment your third team copies a Jenkinsfile from your first team. You've got five repos now, each with a nearly identical &lt;code&gt;stage('Build Docker')&lt;/code&gt; block, and someone just fixed a registry login bug in repo #2 without touching repos #1, #3, #4, or #5. Nobody notices until a deploy fails three weeks later with a stale credential error nobody can reproduce locally.&lt;/p&gt;

&lt;p&gt;That's the trigger. Once you've got 3+ pipelines duplicating the same logic, you extract it into a shared library — and then you hit a fork in the road that every team using the Pipeline: Shared Groovy Libraries plugin eventually hits: do you put your logic in &lt;code&gt;vars/*.groovy&lt;/code&gt; as Global Variables, or do you push it into &lt;code&gt;src/**/*.groovy&lt;/code&gt; as real Groovy classes and keep &lt;code&gt;vars/&lt;/code&gt; as a thin entry point?&lt;/p&gt;

&lt;p&gt;This isn't a cosmetic decision. I've seen teams treat it like a style preference and regret it within a year. The structure you pick determines whether you can unit test your pipeline logic, how much blast radius a bad commit has across every consuming Jenkinsfile, and whether onboarding a fifth or tenth team is a five-minute conversation or a week of confused Slack threads. I've built both, maintained both in production, and I have a strong opinion — which I'll get to.&lt;/p&gt;

&lt;h2&gt;Option A — vars/-only convention (Global Variables)&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;vars/&lt;/code&gt;-only approach means every reusable step — &lt;code&gt;buildDockerImage()&lt;/code&gt;, &lt;code&gt;notifySlack()&lt;/code&gt;, &lt;code&gt;deployToK8s()&lt;/code&gt; — is a Groovy script sitting directly in &lt;code&gt;vars/&lt;/code&gt; with a &lt;code&gt;call()&lt;/code&gt; method. It's the path of least resistance, and honestly, it's how most shared libraries start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; zero boilerplate. You write &lt;code&gt;vars/deployApp.groovy&lt;/code&gt;, define &lt;code&gt;call(Map config)&lt;/code&gt;, and it's immediately usable as &lt;code&gt;deployApp(env: 'staging')&lt;/code&gt; in any Jenkinsfile that imports the library. There's a clean 1:1 mapping between the file name and the pipeline step name, which makes the codebase readable to anyone who already knows Jenkinsfile syntax — no Java/Groovy OOP background required. For prototyping a new shared step, this is the fastest option by far. I've written a working &lt;code&gt;vars/&lt;/code&gt; script in under ten minutes more times than I can count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; there's no real object-oriented design here. No inheritance, no interfaces, no dependency injection. If two scripts need to share state, you end up hacking it through &lt;code&gt;env&lt;/code&gt; variables or global maps passed around like hot potatoes. Worse — unit testing a &lt;code&gt;vars/&lt;/code&gt; script means mocking the entire pipeline DSL (&lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;withCredentials&lt;/code&gt;, &lt;code&gt;input&lt;/code&gt;, all of it), which gets painful fast once the logic branches more than a few times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure mode at scale:&lt;/strong&gt; I watched a &lt;code&gt;vars/deployApp.groovy&lt;/code&gt; grow to 400+ lines because every new requirement — canary logic, rollback, Slack notifications, feature flags — got bolted onto the same &lt;code&gt;call()&lt;/code&gt; method. It became the exact monolith the team extracted the library to avoid. Merge conflicts came back with a vengeance, just one directory level up from where they started.&lt;/p&gt;

&lt;h2&gt;Option B — src/-driven class structure with thin vars/ wrappers&lt;/h2&gt;

&lt;p&gt;The alternative treats the shared library like an actual codebase: real classes in &lt;code&gt;src/org/pkgname/&lt;/code&gt;, with &lt;code&gt;vars/&lt;/code&gt; reduced to a handful of lines that instantiate and delegate. Think &lt;code&gt;DockerBuilder&lt;/code&gt;, &lt;code&gt;SlackNotifier&lt;/code&gt;, &lt;code&gt;K8sDeployer&lt;/code&gt; — each a standalone class with constructor-injected &lt;code&gt;steps&lt;/code&gt; context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; this is where testability actually becomes possible. With &lt;a href="https://github.com/jenkinsci/JenkinsPipelineUnit" rel="noopener noreferrer"&gt;JenkinsPipelineUnit&lt;/a&gt; 1.19+, or Spock if your team prefers it, you can unit test a &lt;code&gt;DockerBuilder&lt;/code&gt; class in complete isolation — no live Jenkins master, no waiting for a build queue. Constructor injection means you're not fighting global state; each class gets exactly the context it needs. Code review also gets dramatically better: a diff to a 40-line class method is something a reviewer can actually reason about, versus a diff buried in a 400-line script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; the learning curve is real for teams that don't have a Java/Groovy background. You'll also run into CPS transformation gotchas — Jenkins Pipeline runs Groovy through a continuation-passing-style transform for durability across restarts, and classes that don't play nice with it throw &lt;code&gt;java.io.NotSerializableException&lt;/code&gt; the moment a build pauses at an &lt;code&gt;input&lt;/code&gt; step and Jenkins needs to serialize the whole call stack. The fix is usually &lt;code&gt;@NonCPS&lt;/code&gt; annotations or marking fields &lt;code&gt;transient&lt;/code&gt;, but it's a debugging session most teams don't expect on day one. There are also just more files to navigate — a one-line config tweak might mean touching three files instead of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it pays off:&lt;/strong&gt; once you're past 10+ consuming pipelines across multiple teams, the compile-time-ish safety of real classes and the ability to actually write tests before merging changes outweighs the extra navigation cost. I've seen this prevent at least two org-wide outages that a &lt;code&gt;vars/&lt;/code&gt;-only structure would have shipped straight to every pipeline simultaneously.&lt;/p&gt;

&lt;h2&gt;Decision matrix&lt;/h2&gt;

&lt;p&gt;Here's how I actually score this when a team asks me to make the call. Weight these against your real numbers, not aspirational ones.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;th&gt;Criteria&lt;/th&gt;
&lt;th&gt;vars/-only wins when...&lt;/th&gt;
&lt;th&gt;src/-driven wins when...&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team size&lt;/td&gt;
&lt;td&gt;1-2 teams, single owner&lt;/td&gt;
&lt;td&gt;3+ teams sharing the library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI job count&lt;/td&gt;
&lt;td&gt;&amp;lt; 5 consuming Jenkinsfiles&lt;/td&gt;
&lt;td&gt;&amp;gt; 10 consuming Jenkinsfiles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need for unit tests&lt;/td&gt;
&lt;td&gt;Low — changes are simple, low risk&lt;/td&gt;
&lt;td&gt;High — compliance, audit trail required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Groovy/OOP proficiency&lt;/td&gt;
&lt;td&gt;Team knows Jenkinsfile syntax only&lt;/td&gt;
&lt;td&gt;Team comfortable with classes, packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Versioning strategy&lt;/td&gt;
&lt;td&gt;Loose, moving fast, prototype phase&lt;/td&gt;
&lt;td&gt;Strict semver tags, changelog discipline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most teams I've worked with don't land on a pure extreme — and that's fine. The realistic middle ground is what most shared libraries look like after 12 months in production: a handful of trivial one-liner steps stay in &lt;code&gt;vars/&lt;/code&gt; (things like &lt;code&gt;notifySlack()&lt;/code&gt; that genuinely don't need a class), while anything with branching logic, external dependencies, or multi-step state gets pushed into &lt;code&gt;src/&lt;/code&gt;. Pure &lt;code&gt;vars/&lt;/code&gt;-only libraries rarely survive past the 400-line monolith stage. Pure &lt;code&gt;src/&lt;/code&gt;-only libraries — with zero thin wrappers — are rare because someone always needs a quick throwaway step and doesn't want to write a class for it.&lt;/p&gt;

&lt;h2&gt;My pick&lt;/h2&gt;

&lt;p&gt;I'll say it plainly: thin &lt;code&gt;vars/&lt;/code&gt; wrappers (10-20 lines, no logic beyond instantiation and delegation) backed by real &lt;code&gt;src/&lt;/code&gt; classes is the only structure I recommend for anything beyond a two-week prototype. It gives you the readable entry point everyone expects from a Jenkinsfile, without letting logic sprawl into an untestable monolith. Below is the layout and code I actually use.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// --- Directory structure (comment-only reference) ---
// ci-shared-lib/
// ├── vars/
// │   └── buildDockerImage.groovy      &amp;lt;- thin entrypoint, calls into src/
// ├── src/
// │   └── com/kuryzhev/ci/
// │       └── DockerBuilder.groovy     &amp;lt;- actual logic, unit-testable
// └── resources/
//     └── com/kuryzhev/ci/templates/Dockerfile.tpl

// ===== vars/buildDockerImage.groovy =====
def call(Map config = [:]) {
    // 'this' is the pipeline script context — pass it explicitly to the class
    def builder = new com.kuryzhev.ci.DockerBuilder(this, config)
    builder.build()
    builder.push()
}

// ===== src/com/kuryzhev/ci/DockerBuilder.groovy =====
package com.kuryzhev.ci

class DockerBuilder implements Serializable {
    def steps          // injected pipeline context (sh, echo, etc.)
    String imageName
    String registry
    String tag

    DockerBuilder(steps, Map config) {
        this.steps = steps
        this.imageName = config.imageName ?: error('imageName is required')
        this.registry  = config.registry  ?: 'registry.kuryzhev.cloud'
        this.tag       = config.tag       ?: steps.env.BUILD_NUMBER
    }

    void build() {
        // libraryResource pulls the templated Dockerfile from resources/
        def dockerfile = steps.libraryResource('com/kuryzhev/ci/templates/Dockerfile.tpl')
        steps.writeFile file: 'Dockerfile.generated', text: dockerfile
        steps.sh "docker build -t ${registry}/${imageName}:${tag} -f Dockerfile.generated ."
    }

    void push() {
        // credentials() binding — never hardcode registry creds in resources/
        steps.withCredentials([steps.usernamePassword(
            credentialsId: 'docker-registry-creds',
            usernameVariable: 'DOCKER_USER',
            passwordVariable: 'DOCKER_PASS'
        )]) {
            steps.sh "echo \$DOCKER_PASS | docker login ${registry} -u \$DOCKER_USER --password-stdin"
            steps.sh "docker push ${registry}/${imageName}:${tag}"
        }
    }
}

// ===== Consuming Jenkinsfile =====
@Library('ci-shared-lib@v2.3.1') _   // pinned version, NOT @main

pipeline {
    agent any
    stages {
        stage('Build &amp;amp; Push') {
            steps {
                buildDockerImage(imageName: 'checkout-service', tag: env.GIT_COMMIT.take(7))
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Two non-negotiables come with this pick. First: pin the library version with git tags — &lt;code&gt;@Library('ci-shared-lib@v2.3.1') _&lt;/code&gt; — and never reference &lt;code&gt;@main&lt;/code&gt; or &lt;code&gt;@master&lt;/code&gt; in a production Jenkinsfile. I've watched a single bad commit to a shared library silently break every consuming pipeline org-wide within minutes, simply because nobody had pinned a version. This one habit alone prevents most "why did prod break at 2am" incidents. Second: mandate JenkinsPipelineUnit tests before merge. Without tests, a shared library isn't infrastructure — it's untested code with an organization-wide blast radius.&lt;/p&gt;

&lt;p&gt;Here's the test that would have caught a regression in &lt;code&gt;DockerBuilder&lt;/code&gt; before it ever reached a consuming pipeline:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// ===== test/groovy/com/kuryzhev/ci/DockerBuilderTest.groovy =====
// Run with JenkinsPipelineUnit 1.19 — no live Jenkins master needed
import com.lesfurets.jenkins.unit.BasePipelineTest
import com.kuryzhev.ci.DockerBuilder
import org.junit.Test
import static org.junit.Assert.assertTrue

class DockerBuilderTest extends BasePipelineTest {

    @Test
    void 'build generates correct docker build command'() {
        def config = [imageName: 'checkout-service', tag: 'abc1234']
        def builder = new DockerBuilder(binding.getVariable('steps'), config)

        builder.build()

        // helper.callStack captures every mocked step invocation
        def shCalls = helper.callStack.findAll { it.methodName == 'sh' }
        assertTrue(shCalls.any { it.args[0].toString().contains('checkout-service:abc1234') })
    }

    @Test(expected = Exception)
    void 'missing imageName throws error'() {
        new DockerBuilder(binding.getVariable('steps'), [:])
    }
}

// Expected console output on `mvn test` / `gradle test`:
//
// DockerBuilderTest &amp;gt; build generates correct docker build command PASSED
// DockerBuilderTest &amp;gt; missing imageName throws error PASSED
//
// BUILD SUCCESSFUL in 2s
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; if you skip the constructor injection pattern and let a &lt;code&gt;src/&lt;/code&gt; class call &lt;code&gt;sh&lt;/code&gt; or &lt;code&gt;env&lt;/code&gt; directly without passing &lt;code&gt;steps&lt;/code&gt; in, you'll hit &lt;code&gt;groovy.lang.MissingPropertyException: No such property: steps&lt;/code&gt; the first time it's actually invoked inside a pipeline — it works fine in isolation until it doesn't. And if a field on your class holds something non-serializable across an &lt;code&gt;input&lt;/code&gt; or long-running &lt;code&gt;sh&lt;/code&gt; step, expect &lt;code&gt;NotSerializableException&lt;/code&gt; the moment Jenkins tries to persist pipeline state mid-build. Mark it &lt;code&gt;transient&lt;/code&gt; or move the logic into a &lt;code&gt;@NonCPS&lt;/code&gt; method.&lt;/p&gt;

&lt;p&gt;One more thing worth flagging on the security side: don't dump credentials into &lt;code&gt;resources/&lt;/code&gt; files bundled with the library repo. That's a fast way to leak secrets into git history permanently. Use Jenkins' &lt;code&gt;credentials()&lt;/code&gt; binding every time, as shown in the &lt;code&gt;push()&lt;/code&gt; method above. And when the sandbox rejects an unapproved Groovy method call, resist the urge to blanket-approve everything in Manage Jenkins → In-process Script Approval — audit each approval individually, especially anything touching &lt;code&gt;@Grab&lt;/code&gt; or external classloaders.&lt;/p&gt;

&lt;p&gt;For teams sitting at more than 50 builds a day, also be deliberate about how the library loads. Loading a heavyweight &lt;code&gt;@Library&lt;/code&gt; implicitly at the top of every Jenkinsfile means a fresh checkout and Groovy compile on every single build — that adds up. Reserve implicit loading for libraries genuinely used everywhere, and lazy-load the rest with the &lt;code&gt;library 'name'&lt;/code&gt; step inside the specific stage that needs it. We've covered similar CI/CD pipeline hardening patterns over on &lt;a href="https://kuryzhev.cloud/category/ci-cd/" rel="noopener noreferrer"&gt;kuryzhev.cloud's CI/CD category&lt;/a&gt; if you want more war stories from this side of the pipeline.&lt;/p&gt;

&lt;p&gt;Get the shared library structure right once, pin your versions, write the tests, and this stops being a recurring 2am problem. Get it wrong, and every team you onboard multiplies the risk instead of the value. Full reference on the plugin mechanics is in the &lt;a href="https://www.jenkins.io/doc/book/pipeline/shared-libraries/" rel="noopener noreferrer"&gt;official Jenkins shared libraries documentation&lt;/a&gt; — worth reading end to end before you commit to a structure.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/ci-cd/" rel="noopener noreferrer"&gt;More CI/CD pipeline patterns, quality gates, and rollback strategies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/docker/" rel="noopener noreferrer"&gt;Docker build and registry practices for production pipelines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;Secrets management and credential handling in CI systems&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Helm Rollback Strategy: Safe Values Promotion in Production</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Thu, 06 Aug 2026 07:01:43 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/helm-rollback-strategy-safe-values-promotion-in-production-3cge</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/helm-rollback-strategy-safe-values-promotion-in-production-3cge</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/06/helm-rollback-strategy-safe-values-promotion-in-production" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;One bad &lt;code&gt;helm upgrade&lt;/code&gt; and your production values file is history — literally — unless you have a helm rollback strategy that survives concurrent deploys, stateful side effects, and plain human error. I've watched a team roll back to what they *thought* was the last good revision, only to land on a "superseded" release with a broken feature flag still baked in. Here's what we changed after that incident, and what we now enforce before every promotion.&lt;/p&gt;

&lt;h2&gt;Pin exact chart versions and snapshot values before every promotion&lt;/h2&gt;



&lt;p&gt;The biggest source of rollback confusion is not knowing what was actually deployed. &lt;code&gt;values.yaml&lt;/code&gt; in your chart repo is not the source of truth — the live cluster state can drift from it after hotfixes, manual &lt;code&gt;--set&lt;/code&gt; overrides, or an emergency patch someone forgot to commit. Before every &lt;code&gt;helm upgrade&lt;/code&gt;, run &lt;code&gt;helm get values &amp;lt;release&amp;gt; -o yaml&lt;/code&gt; and store it alongside your CI artifacts, tagged with the git commit SHA and the release revision number. If you can't answer "what values were live five minutes ago" in under 30 seconds, your rollback plan is theoretical, not real.&lt;/p&gt;

&lt;h2&gt;Roll back by revision number, never by "previous" assumptions&lt;/h2&gt;

&lt;p&gt;Always run &lt;code&gt;helm history &amp;lt;release&amp;gt;&lt;/code&gt; first and confirm the exact revision status before touching anything. &lt;code&gt;helm rollback &amp;lt;release&amp;gt; 0&lt;/code&gt; means "go to previous revision" — and that's dangerous the moment two deploys happen close together, because "previous" might be a failed or superseded revision, not the last known-good one. I stopped trusting revision 0 after a rollback landed us on a revision that had already been marked superseded due to a race between two pipeline runs.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Wrong: assumes "previous" is safe
helm rollback payments-api 0 -n prod

# Right: explicit revision confirmed via helm history first
helm history payments-api -n prod --max 5
helm rollback payments-api 41 -n prod --wait --timeout 5m
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Watch out: without &lt;code&gt;--wait --timeout 5m&lt;/code&gt;, Helm — and your CI/CD pipeline — will report success the instant the rollback command is accepted, not when pods are actually healthy. That's how you get a "successful" deploy alert five minutes before the real outage.&lt;/p&gt;

&lt;h2&gt;Increase --history-max deliberately, but know the storage tradeoff&lt;/h2&gt;

&lt;p&gt;Helm's default &lt;code&gt;--history-max=10&lt;/code&gt; is fine for low-churn services, but if you've had a bad deploy streak, ten revisions can disappear fast. Each revision is stored as a Kubernetes Secret in the release namespace (&lt;code&gt;sh.helm.release.v1.&amp;lt;name&amp;gt;.v&amp;lt;rev&amp;gt;&lt;/code&gt;), so bumping history-max isn't free — on a release doing 50+ deploys a day, that's real etcd bloat and slower &lt;code&gt;helm list&lt;/code&gt;/&lt;code&gt;helm history&lt;/code&gt; calls. We settled on 15–20 for critical services and purge history entirely on decommissioned releases with &lt;code&gt;helm uninstall --keep-history=false&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Diff before you rollback — don't trust memory&lt;/h2&gt;

&lt;p&gt;Install the &lt;code&gt;helm-diff&lt;/code&gt; plugin (v3.9+) and run &lt;code&gt;helm diff rollback &amp;lt;release&amp;gt; &amp;lt;revision&amp;gt;&lt;/code&gt; before you commit to anything. Combine it with &lt;code&gt;helm rollback --dry-run --debug&lt;/code&gt; to surface hook ordering or immutable field issues before they hit the cluster. Skipping this because "it's just a rollback" is the mistake I see most often — values promoted since that revision (secrets, replica counts, resource limits) can silently regress and nobody notices until the pods start OOMKilling.&lt;/p&gt;

&lt;h2&gt;Rollback doesn't undo everything — watch stateful side effects&lt;/h2&gt;

&lt;p&gt;This is the misconception that bites teams hardest: Helm rollback only reverts the Kubernetes objects Helm tracks. It does not rerun &lt;code&gt;pre-upgrade&lt;/code&gt; hooks, does not undo a database migration, and does not restore PVC data. If your Job has &lt;code&gt;helm.sh/hook: pre-upgrade&lt;/code&gt; with &lt;code&gt;hook-delete-policy: before-hook-creation&lt;/code&gt;, a rollback can leave that Job orphaned in the namespace — audit it manually, don't assume Helm cleaned up after itself. Treating a Helm rollback as equivalent to a full &lt;code&gt;git revert&lt;/code&gt; for the entire system is how migrations end up permanently mismatched with the application code they were supposed to support.&lt;/p&gt;

&lt;h2&gt;Promote values through environments, not through manual edits&lt;/h2&gt;

&lt;p&gt;Use layered values files — &lt;code&gt;values-base.yaml&lt;/code&gt;, &lt;code&gt;values-staging.yaml&lt;/code&gt;, &lt;code&gt;values-prod.yaml&lt;/code&gt; — merged in a fixed &lt;code&gt;-f&lt;/code&gt; order, and never inline &lt;code&gt;--set&lt;/code&gt; flags in production pipelines. Promotion should mean copying a validated, already-tested values file from staging to prod through a CI step with a visible diff, not someone re-typing numbers from memory on a Friday afternoon.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# values-base.yaml — shared defaults, no secrets
replicaCount: 2
image:
  repository: registry.internal/payments-api
  tag: "1.8.2"          # pinned explicitly, promoted via CI, never "latest"

resources:
  requests:
    cpu: 250m
    memory: 256Mi

---
# values-prod.yaml — env-specific overrides
replicaCount: 6
resources:
  requests:
    cpu: 500m
    memory: 512Mi
secretsRef:
  # Real values live in a SOPS-encrypted file, referenced here, never inlined
  name: payments-api-secrets-sops
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Keep secrets out of these files entirely. Use SOPS or the &lt;a href="https://kubernetes-sigs.github.io/external-dns/latest/" rel="nofollow noopener noreferrer"&gt;External Secrets Operator&lt;/a&gt; pattern so a rollback never resurrects an old plaintext credential sitting in Secret history from three revisions ago — that's a real security consideration, not a hypothetical one.&lt;/p&gt;

&lt;h2&gt;Test the rollback path itself, not just the deploy path&lt;/h2&gt;

&lt;p&gt;Run rollback drills in a staging namespace on a schedule — monthly is enough — to confirm &lt;code&gt;helm rollback&lt;/code&gt; actually restores service without someone SSH-ing in to patch things manually. Lock CI/CD concurrency per release with a mutex or a &lt;code&gt;concurrency:&lt;/code&gt; group in GitHub Actions so a rollback and a fresh deploy can never race against each other. The worst time to discover your rollback path fails on an immutable field error (looking at you, Deployment &lt;code&gt;selector&lt;/code&gt; changes) is during a live incident at 2am.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/usr/bin/env bash
# rollback-with-snapshot.sh
# Safe Helm rollback workflow: snapshot current values, diff, confirm, rollback.

set -euo pipefail

RELEASE="payments-api"
NAMESPACE="prod"
SNAPSHOT_DIR="./values-snapshots"

mkdir -p "$SNAPSHOT_DIR"

echo "==&amp;gt; Fetching release history"
helm history "$RELEASE" -n "$NAMESPACE" -o json &amp;gt; "${SNAPSHOT_DIR}/history-$(date +%s).json"

# List the last 5 revisions for operator review
helm history "$RELEASE" -n "$NAMESPACE" --max 5

read -rp "Enter target revision number to rollback to: " TARGET_REV

echo "==&amp;gt; Snapshotting CURRENT live values before touching anything"
helm get values "$RELEASE" -n "$NAMESPACE" -o yaml \
  &amp;gt; "${SNAPSHOT_DIR}/current-before-rollback-$(date +%Y%m%d%H%M).yaml"

echo "==&amp;gt; Snapshotting TARGET revision values for comparison"
helm get values "$RELEASE" -n "$NAMESPACE" --revision "$TARGET_REV" -o yaml \
  &amp;gt; "${SNAPSHOT_DIR}/target-rev-${TARGET_REV}.yaml"

echo "==&amp;gt; Diffing current vs target (requires helm-diff plugin)"
helm diff rollback "$RELEASE" "$TARGET_REV" -n "$NAMESPACE" || true

read -rp "Proceed with rollback to revision ${TARGET_REV}? (yes/no) " CONFIRM
if [[ "$CONFIRM" != "yes" ]]; then
  echo "Aborted."
  exit 1
fi

echo "==&amp;gt; Executing rollback"
helm rollback "$RELEASE" "$TARGET_REV" \
  -n "$NAMESPACE" \
  --wait \
  --timeout 5m \
  --history-max 15

echo "==&amp;gt; Post-rollback verification"
helm status "$RELEASE" -n "$NAMESPACE"
kubectl get pods -n "$NAMESPACE" -l app.kubernetes.io/instance="$RELEASE"

echo "Done. Snapshots saved in ${SNAPSHOT_DIR}/"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If you're running ArgoCD or Flux, this whole helm rollback strategy shifts: prefer &lt;code&gt;git revert&lt;/code&gt; plus a sync over a manual &lt;code&gt;helm rollback&lt;/code&gt;, so you don't end up with git and cluster state disagreeing about what "current" means. We cover more of that gitops-vs-manual tradeoff in our &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS&lt;/a&gt; archive if you want the longer version. Whatever pattern you pick, the goal is the same: a rollback should be boring, tested, and never the first time you've actually run the command in anger.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/kubernetes/" rel="noopener noreferrer"&gt;More Kubernetes deployment patterns and troubleshooting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/gitops/" rel="noopener noreferrer"&gt;GitOps workflows with ArgoCD and Flux for safer syncs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/helm/" rel="noopener noreferrer"&gt;More Helm chart patterns and values management&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
    <item>
      <title>EKS IRSA Setup: Fix S3 AccessDenied From Node IAM Roles</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Wed, 05 Aug 2026 07:01:44 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/eks-irsa-setup-fix-s3-accessdenied-from-node-iam-roles-5fob</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/eks-irsa-setup-fix-s3-accessdenied-from-node-iam-roles-5fob</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/08/05/eks-irsa-setup-fix-s3-accessdenied-from-node-iam-roles" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;The problem we hit&lt;/h2&gt;



&lt;p&gt;It was 3:40am when the pager went off. A backup CronJob that had been running fine for months suddenly started throwing &lt;code&gt;AccessDenied&lt;/code&gt; on &lt;code&gt;s3:PutObject&lt;/code&gt;. Nothing had changed in the application code, no deploy had gone out, and the IAM policy attached to the role looked exactly like it always had. The only thing that had happened was a "routine" node group rotation earlier that evening — new nodes, same AMI family, same everything, or so we thought.&lt;/p&gt;

&lt;p&gt;The on-call engineer's first instinct was to check the IAM role tied to the service account. Policy looked correct: &lt;code&gt;s3:PutObject&lt;/code&gt; on the right bucket ARN. But the pod kept failing. Digging into CloudTrail, the actual caller wasn't the intended IRSA role at all — it was &lt;code&gt;arn:aws:sts::&amp;lt;acct&amp;gt;:assumed-role/eksctl-nodegroup-role/i-0abcd...&lt;/code&gt;. The pod was quietly falling back to the EC2 instance profile attached to the node, not the role we thought we'd scoped down months ago.&lt;/p&gt;

&lt;p&gt;That's when the real scope of the problem became clear. The node's IAM role was shared across twelve other unrelated pods on the same node group — logging sidecars, a metrics exporter, a couple of internal APIs. All of them had been running with broad, node-level permissions the entire time, and nobody noticed because everything "worked." The backup job breaking was almost a lucky accident — it forced us to look at something that had been a ticking time bomb for a long time. Overprivileged access doesn't announce itself; it just sits there until an incident makes you look.&lt;/p&gt;

&lt;h2&gt;Why it happens&lt;/h2&gt;

&lt;p&gt;Without a proper &lt;strong&gt;EKS IRSA setup&lt;/strong&gt;, pods don't get their own AWS identity — they inherit whatever the EC2 instance profile on the node grants. That's the default behavior of the AWS SDK credential chain: if no explicit credentials are found, it walks up to instance metadata and grabs the node's role. It's broad, it's shared across every pod scheduled on that node, and it's nearly impossible to trace a specific API call back to a specific workload in CloudTrail unless you know exactly what to look for.&lt;/p&gt;

&lt;p&gt;IRSA (IAM Roles for Service Accounts) fixes this by binding a Kubernetes ServiceAccount to a specific IAM role via OIDC federation. Here's the chain: the EKS cluster has an OIDC identity provider registered in IAM. A ServiceAccount gets annotated with &lt;code&gt;eks.amazonaws.com/role-arn&lt;/code&gt;. When a pod using that ServiceAccount starts, a mutating webhook injects a projected token volume and two environment variables — &lt;code&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/code&gt; and &lt;code&gt;AWS_ROLE_ARN&lt;/code&gt;. The AWS SDK reads that token, calls &lt;code&gt;sts:AssumeRoleWithWebIdentity&lt;/code&gt;, and gets short-lived credentials scoped to exactly that role.&lt;/p&gt;

&lt;p&gt;In our incident, two things had gone wrong. First, the cluster had been recreated via Terraform months earlier and the OIDC provider registration hadn't been re-associated — a classic drift issue that nobody caught because most workloads still "worked" using node-role fallback. Second, and this is the gotcha that really got us: the trust policy's &lt;code&gt;Condition&lt;/code&gt; block had a subtly wrong &lt;code&gt;sub&lt;/code&gt; value from an earlier copy-paste — the namespace didn't match exactly. When the &lt;code&gt;AssumeRoleWithWebIdentity&lt;/code&gt; call failed silently, several SDKs just fell back to the default credentials chain instead of erroring loudly. That's the dangerous part — it doesn't crash, it just quietly uses the wrong identity.&lt;/p&gt;

&lt;h2&gt;The fix (with code)&lt;/h2&gt;

&lt;p&gt;The fix has three parts: confirm the OIDC provider is actually registered, write a trust policy scoped to an exact namespace and service account (no wildcards, ever), and attach a least-privilege policy instead of reaching for &lt;code&gt;AmazonS3FullAccess&lt;/code&gt; as a "temporary" patch. We ran this on EKS 1.27 with &lt;code&gt;eksctl&lt;/code&gt; 1.147.0 and &lt;code&gt;aws-cli&lt;/code&gt; 2.15.x — worth noting that anything older than aws-cli 2.9 doesn't play nicely with IRSA debugging defaults.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/usr/bin/env bash
# irsa-setup.sh — reproduce the fix from the incident: scoped IAM role for a single ServiceAccount
set -euo pipefail

CLUSTER_NAME="prod-eks-01"
NAMESPACE="backup-jobs"
SERVICE_ACCOUNT="s3-backup-sa"
BUCKET_ARN="arn:aws:s3:::acme-backup-bucket"
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

# 1. Confirm OIDC provider exists (this was the actual root cause in our incident —
#    the provider was missing after a cluster recreate via Terraform)
OIDC_ISSUER=$(aws eks describe-cluster --name "$CLUSTER_NAME" \
  --query "cluster.identity.oidc.issuer" --output text)
OIDC_ID=$(echo "$OIDC_ISSUER" | sed 's|https://oidc.eks.*/id/||')

if ! aws iam list-open-id-connect-providers | grep -q "$OIDC_ID"; then
  echo "OIDC provider missing — associating now"
  eksctl utils associate-iam-oidc-provider --cluster "$CLUSTER_NAME" --approve
fi

# 2. Write a trust policy scoped to exact namespace:serviceaccount (no wildcards)
cat &amp;gt; trust-policy.json &amp;lt;&amp;lt;EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::${ACCOUNT_ID}:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/${OIDC_ID}" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "oidc.eks.us-east-1.amazonaws.com/id/${OIDC_ID}:sub": "system:serviceaccount:${NAMESPACE}:${SERVICE_ACCOUNT}",
        "oidc.eks.us-east-1.amazonaws.com/id/${OIDC_ID}:aud": "sts.amazonaws.com"
      }
    }
  }]
}
EOF

# 3. Create the role — least privilege, no AmazonS3FullAccess "temporary fix"
aws iam create-role \
  --role-name irsa-s3-backup \
  --assume-role-policy-document file://trust-policy.json

cat &amp;gt; s3-policy.json &amp;lt;&amp;lt;EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject", "s3:GetObject"],
    "Resource": "${BUCKET_ARN}/*"
  }]
}
EOF

aws iam put-role-policy \
  --role-name irsa-s3-backup \
  --policy-name s3-backup-put \
  --policy-document file://s3-policy.json

echo "Role ARN: arn:aws:iam::${ACCOUNT_ID}:role/irsa-s3-backup"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once the role exists, the ServiceAccount needs the annotation applied &lt;em&gt;before&lt;/em&gt; the pod is created — this bit us too. The mutating webhook only injects the token env vars at admission time, so annotating an existing ServiceAccount and doing a rolling update won't help until the pods are fully recreated.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# service-account.yaml — annotate BEFORE the pod is created; webhook injects env vars at admission time
apiVersion: v1
kind: ServiceAccount
metadata:
  name: s3-backup-sa
  namespace: backup-jobs
  annotations:
    eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/irsa-s3-backup"
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: s3-backup-job
  namespace: backup-jobs
spec:
  schedule: "0 3 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: s3-backup-sa  # must match trust policy "sub" exactly
          containers:
          - name: backup
            image: amazon/aws-cli:2.15.0
            command: ["aws", "s3", "cp", "/data/dump.tar.gz", "s3://acme-backup-bucket/"]
          restartPolicy: OnFailure

# Verification after apply:
# kubectl exec -it &amp;lt;pod&amp;gt; -n backup-jobs -- aws sts get-caller-identity
# Expected output:
# {
#   "UserId": "AROAEXAMPLE:botocore-session-1234567890",
#   "Account": "123456789012",
#   "Arn": "arn:aws:sts::123456789012:assumed-role/irsa-s3-backup/botocore-session-1234567890"
# }
# If Arn instead shows "assumed-role/eksctl-nodegroup-role/..." — annotation wasn't
# applied before pod creation, or SA name/namespace mismatch in trust policy.
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Also worth checking manually: the token file lives at &lt;code&gt;/var/run/secrets/eks.amazonaws.com/serviceaccount/token&lt;/code&gt; inside the pod. If that file is missing, the webhook never fired — usually because the annotation went on too late. I stopped trusting "it should be fine, we applied the annotation" the moment I saw this happen twice in the same week across two different teams.&lt;/p&gt;

&lt;h2&gt;Prevention checklist&lt;/h2&gt;

&lt;p&gt;This class of incident is entirely preventable with a few disciplined habits baked into how you manage IAM for EKS workloads.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One IAM role per workload, never shared.&lt;/strong&gt; Reusing a role across unrelated pods is exactly how a backup job's misconfiguration turns into a fleet-wide privilege audit. Run &lt;code&gt;aws iam list-roles | grep irsa&lt;/code&gt; periodically and map each role back to a single namespace/service account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope trust policies exactly — no wildcards.&lt;/strong&gt; The &lt;code&gt;Condition&lt;/code&gt; block must use &lt;code&gt;StringEquals&lt;/code&gt; with the precise &lt;code&gt;sub&lt;/code&gt; value: &lt;code&gt;system:serviceaccount:&amp;lt;namespace&amp;gt;:&amp;lt;sa-name&amp;gt;&lt;/code&gt;. A typo or wrong namespace doesn't throw a loud error — it silently falls back to node credentials in some SDKs, which is worse than an outright failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect OIDC provider drift automatically.&lt;/strong&gt; After any cluster upgrade or recreation, verify the provider is still registered with &lt;code&gt;aws eks describe-cluster --name &amp;lt;cluster&amp;gt; --query "cluster.identity.oidc.issuer"&lt;/code&gt; against your IAM provider list. Wire this into a CI job or a Terraform plan check so it never depends on someone remembering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never leave "temporary" broad policies in place.&lt;/strong&gt; If you attach &lt;code&gt;AmazonS3FullAccess&lt;/code&gt; to unblock an incident at 4am, put a ticket and a revert date on it immediately. It will not get revisited otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segregate roles by environment.&lt;/strong&gt; Don't reuse the same trust policy pattern across dev, staging, and prod namespaces — that's how privilege leaks across environments during a "quick copy-paste."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a tested module instead of hand-rolled JSON.&lt;/strong&gt; The &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-iam/tree/master/modules/iam-role-for-service-accounts-eks" rel="noopener noreferrer"&gt;terraform-aws-modules IRSA module&lt;/a&gt; generates trust policies correctly and removes an entire class of typo bugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting the EKS IRSA setup right isn't a one-time task — it's an ongoing discipline. We now run a quarterly audit that cross-references every ServiceAccount annotation against its IAM role and trust policy, and we treat any mismatch as a P2. For more patterns on locking down cloud IAM without breaking workloads, check out the &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS&lt;/a&gt; archive — there's a related piece on Terraform-managed Vault secrets that pairs well with this.&lt;/p&gt;

&lt;p&gt;For the official reference, AWS's own &lt;a href="https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html" rel="noopener noreferrer"&gt;IAM roles for service accounts documentation&lt;/a&gt; is worth bookmarking — it's the source of truth for webhook behavior across EKS versions.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/aws/" rel="noopener noreferrer"&gt;More AWS automation and IAM hardening patterns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/kubernetes/" rel="noopener noreferrer"&gt;Kubernetes cluster reliability and RBAC deep dives&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Terraform patterns for managing AWS IAM and secrets safely&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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