<?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 DRA Extended Resources: GPUs Without Device Plugins</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sat, 07 Nov 2026 08:01:34 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/kubernetes-dra-extended-resources-gpus-without-device-plugins-b2d</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/kubernetes-dra-extended-resources-gpus-without-device-plugins-b2d</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/11/07/kubernetes-dra-extended-resources-gpus-without-device-plugins" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A platform team has 200 training manifests that all say &lt;code&gt;nvidia.com/gpu: 1&lt;/code&gt;. They also want per-model GPU selection, MIG slices and shared devices, which the device plugin model cannot express. Rewriting every manifest to use ResourceClaims would be a large, slow and risky project. &lt;strong&gt;DRA extended resources&lt;/strong&gt; exist to remove that trade-off.&lt;/p&gt;

&lt;p&gt;Kubernetes v1.37 graduated DRA extended resources to GA, as covered in the &lt;a href="https://kubernetes.io/blog/2026/08/26/kubernetes-v1-37-release/" rel="noopener noreferrer"&gt;v1.37 release announcement&lt;/a&gt;. It matters now because the migration path is finally stable: you can move GPU nodes from a device plugin to a Dynamic Resource Allocation (DRA) driver without touching workload YAML.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; install a DRA driver for your GPUs, create a DeviceClass that declares the extended resource name it should satisfy, and keep requesting &lt;code&gt;nvidia.com/gpu&lt;/code&gt; in &lt;code&gt;resources.limits&lt;/code&gt;. Roll it out node pool by node pool. It applies if you run Kubernetes 1.37+ and want DRA features, but skip it if a device plugin already does everything you need.&lt;/p&gt;

&lt;h2&gt;What DRA extended resources actually do&lt;/h2&gt;



&lt;p&gt;Classic extended resources work like this. A device plugin on each node tells the kubelet "I have 8 of &lt;code&gt;nvidia.com/gpu&lt;/code&gt;", and the count shows up in node allocatable. The scheduler treats it as an opaque integer. It cannot tell an H100 from a T4, and it cannot see topology or partitions.&lt;/p&gt;

&lt;p&gt;DRA replaces that integer with structured data. A driver publishes &lt;code&gt;ResourceSlice&lt;/code&gt; objects describing real devices and their attributes. The scheduler allocates specific devices through &lt;code&gt;ResourceClaim&lt;/code&gt; objects, using CEL selectors in a &lt;code&gt;DeviceClass&lt;/code&gt; or claim.&lt;/p&gt;

&lt;p&gt;The extended resources bridge connects the two worlds. A DeviceClass can name an extended resource, for example &lt;code&gt;nvidia.com/gpu&lt;/code&gt;. When a pod asks for that name in its limits, the scheduler creates a claim on the pod's behalf, allocates a device from the matching class, and records the result in the pod status. Per the KEP and docs, the pod author sees no difference, while the platform team gets DRA's allocation model underneath.&lt;/p&gt;

&lt;p&gt;The details (field names, status fields, driver support) are documented in the &lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/" rel="noopener noreferrer"&gt;DRA documentation&lt;/a&gt;. Confirm them against your exact patch version with &lt;code&gt;kubectl explain deviceclass.spec&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;How people use it wrong&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Running both stacks on the same GPUs.&lt;/strong&gt; A typical failure is installing the DRA driver while the device plugin DaemonSet is still running. Both then advertise the same physical devices through different mechanisms, and the system can over-promise. Pods land on a node, one path says the GPU is free, and the container fails at start or two pods fight over a device. The documented guidance is that a device is managed by one mechanism at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rewriting everything to ResourceClaims on day one.&lt;/strong&gt; Teams read "DRA" and convert all manifests to &lt;code&gt;resourceClaims&lt;/code&gt; plus &lt;code&gt;ResourceClaimTemplate&lt;/code&gt;. That is only needed for workloads that need selection or sharing. The bridge exists so most pods can stay unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treating it as a drop-in for any cluster.&lt;/strong&gt; Mixed-version node pools, older kubelets, or a driver that does not support the extended resource mapping will produce pods stuck in Pending. Check your driver's release notes, not just the Kubernetes version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting the accounting side.&lt;/strong&gt; Quotas, cost dashboards and alerts often key off node allocatable &lt;code&gt;nvidia.com/gpu&lt;/code&gt;. Once a pool moves to DRA, that number may no longer be the source of truth. Verify how your quota and metrics tooling treat DRA-allocated devices before you cut over.&lt;/p&gt;

&lt;h2&gt;The correct approach&lt;/h2&gt;

&lt;p&gt;Migrate one labeled node pool at a time. Follow these steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cordon and drain the pool.&lt;/li&gt;
&lt;li&gt;Remove the device plugin from it. For example, add node affinity to the device plugin DaemonSet that excludes the pool's label.&lt;/li&gt;
&lt;li&gt;Install the vendor DRA driver.&lt;/li&gt;
&lt;li&gt;Create the DeviceClass.&lt;/li&gt;
&lt;li&gt;Uncordon the nodes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The NVIDIA driver is distributed as the &lt;code&gt;nvidia-dra-driver-gpu&lt;/code&gt; Helm chart. Follow its README for current values, because chart options change between releases.&lt;/p&gt;

&lt;p&gt;This DeviceClass maps the legacy resource name onto devices from the driver. The driver name &lt;code&gt;gpu.nvidia.com&lt;/code&gt; is NVIDIA's; substitute your vendor's. The CEL expression is the part to check against your driver's published attributes.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
  name: gpu.example.com-default
spec:
  selectors:
  - cel:
      expression: device.driver == "gpu.nvidia.com"   # match only this driver's devices
  extendedResourceName: nvidia.com/gpu                # pods requesting this name get a DRA-backed claim
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;🧪 Lab — try this in your browser&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.kuryzhev.cloud/?utm_source=devto&amp;amp;utm_medium=lab&amp;amp;utm_campaign=kubernetes-dra-extended-resources-gpus-without-device-plugins&amp;amp;input=apiVersion%3A%20v1%0Akind%3A%20Pod%0Ametadata%3A%0A%20%20name%3A%20gpu-smoke-test%0Aspec%3A%0A%20%20restartPolicy%3A%20Never%0A%20%20containers%3A%0A%20%20-%20name%3A%20cuda%0A%20%20%20%20image%3A%20nvcr.io%2Fnvidia%2Fcuda%3A%3Ctag%3E%20%20%20%23%20pick%20a%20CUDA%20base%20image%20matching%20your%20driver%0A%20%20%20%20command%3A%20%5B%22nvidia-smi%22%2C%20%22-L%22%5D%0A%20%20%20%20resources%3A%0A%20%20%20%20%20%20limits%3A%0A%20%20%20%20%20%20%20%20nvidia.com%2Fgpu%3A%201%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20unchanged%20legacy%20request#k8slint" rel="noopener noreferrer"&gt;Kubernetes Manifest Linter&lt;/a&gt; — Lint multi-document Kubernetes YAML for production readiness and security, with line:col diagnostics, severity grouping, a 0-100 score and fix hints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workload stays exactly as it was. Nothing in it mentions DRA:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: Pod
metadata:
  name: gpu-smoke-test
spec:
  restartPolicy: Never
  containers:
  - name: cuda
    image: nvcr.io/nvidia/cuda:&amp;lt;tag&amp;gt;   # pick a CUDA base image matching your driver
    command: ["nvidia-smi", "-L"]
    resources:
      limits:
        nvidia.com/gpu: 1               # unchanged legacy request
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;To verify, check three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The driver is publishing devices.&lt;/strong&gt; &lt;code&gt;kubectl get resourceslices&lt;/code&gt; should list one or more slices per GPU node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A claim was generated for the pod.&lt;/strong&gt; &lt;code&gt;kubectl get resourceclaims&lt;/code&gt; should show a claim in the pod's namespace in an allocated state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pod status records the claim.&lt;/strong&gt; Run &lt;code&gt;kubectl get pod gpu-smoke-test -o yaml&lt;/code&gt; and look for the extended resource claim status section.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then &lt;code&gt;kubectl logs gpu-smoke-test&lt;/code&gt; should print one GPU line from &lt;code&gt;nvidia-smi -L&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; if the pod stays Pending, run &lt;code&gt;kubectl describe pod&lt;/code&gt; and read the scheduler events first. The usual causes are a missing &lt;code&gt;extendedResourceName&lt;/code&gt; on the DeviceClass or a CEL expression that matches nothing.&lt;/p&gt;

&lt;h2&gt;Advanced patterns&lt;/h2&gt;

&lt;p&gt;Once a pool runs on DRA, you can mix styles. Most pods keep the legacy request. The few that need a specific GPU model, a MIG profile or a shared device use an explicit &lt;code&gt;ResourceClaimTemplate&lt;/code&gt; with a CEL selector on device attributes. Run &lt;code&gt;kubectl get resourceslices -o yaml&lt;/code&gt; to see the attribute names your driver really publishes before writing selectors.&lt;/p&gt;

&lt;p&gt;You can also define several DeviceClasses for different tiers, for example one that excludes older cards. Teams then get access through namespaces and RBAC on claim templates. That moves policy from node labels and taints into declarative objects the scheduler understands.&lt;/p&gt;

&lt;p&gt;Multi-pod jobs are another fit. v1.37 also ships Workload/PodGroup gang scheduling in beta, so the pods of a distributed training job can be scheduled together instead of a partial set starting and holding devices. Because it is beta, check the release notes for its feature gates, API shape and how it interacts with DRA allocation before relying on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; the bridge only covers the simple "N devices of a kind" case. If you need a pod to combine a legacy request and rich selection on the same device, test that exact combination first. The docs list the supported interactions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When not to bother:&lt;/strong&gt; a small fleet of identical GPUs with whole-device allocation and no sharing needs gets little from DRA. You would add a driver, new API objects to debug and a new failure surface in exchange for features you do not use. The device plugin is still a fine choice there.&lt;/p&gt;

&lt;h2&gt;Performance notes&lt;/h2&gt;

&lt;p&gt;The costs are different from the device plugin's, not necessarily bigger. The scheduler now runs a DynamicResources plugin that evaluates CEL selectors against ResourceSlices, and the control plane stores slices and claims. Large GPU fleets with many short-lived pods generate a claim object per pod, so watch API server object counts and etcd size.&lt;/p&gt;

&lt;p&gt;I won't quote numbers, because they depend on cluster size and driver. Measure instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In Prometheus, compare &lt;code&gt;scheduler_pending_pods&lt;/code&gt; before and after migrating a pool.&lt;/li&gt;
&lt;li&gt;Compare the scheduler plugin duration histogram &lt;code&gt;scheduler_plugin_execution_duration_seconds&lt;/code&gt;, filtered to the DynamicResources plugin.&lt;/li&gt;
&lt;li&gt;Track pod startup time from scheduled to running, since the kubelet must call the driver to prepare devices before the container starts. Your driver documents its own metrics for that step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For rollback, keep the old device plugin manifest ready and migrate pools one at a time. Reverting a pool then means draining it, removing the driver and restoring the plugin. For related platform patterns, browse more posts on &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;. The authoritative reference for allocation semantics, status fields and supported drivers is the &lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/" rel="noopener noreferrer"&gt;Kubernetes DRA documentation&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/01/rolling-out-kubernetes-pod-security-standards-without-breaking-prod/" rel="noopener noreferrer"&gt;Rolling Out Kubernetes Pod Security Standards Without Breaking Prod&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/11/01/kubernetes-networkpolicy-baseline-3-mistakes-that-break-isolation/" rel="noopener noreferrer"&gt;Kubernetes NetworkPolicy Baseline: 3 Mistakes That Break Isolation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/11/kubernetes-ingress-manifest-or-gateway-api-which-to-ship/" rel="noopener noreferrer"&gt;Kubernetes Ingress Manifest or Gateway API: Which to Ship&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
    <item>
      <title>CRA vulnerability reporting: a 24h and 72h runbook for small teams</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Fri, 06 Nov 2026 08:02:12 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/cra-vulnerability-reporting-a-24h-and-72h-runbook-for-small-teams-500p</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/cra-vulnerability-reporting-a-24h-and-72h-runbook-for-small-teams-500p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/11/06/cra-vulnerability-reporting-a-24h-and-72h-runbook-for-small-teams" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;The scenario: a Friday-evening exploit report&lt;/h2&gt;

&lt;p&gt;It is 18:40 on a Friday. A customer emails your two-person team a proof-of-concept for your firmware update daemon, with a link to a forum thread where someone is already using it. Before September 2026 that was a bad evening. Now it also starts a legal clock, because &lt;strong&gt;CRA vulnerability reporting&lt;/strong&gt; has been mandatory since 11 Sep 2026. For actively exploited vulnerabilities, manufacturers of products with digital elements must send an early warning within 24 hours and a fuller notification within 72 hours. The Commission's overview is at &lt;a href="https://digital-strategy.ec.europa.eu/en/policies/cra-reporting" rel="noopener noreferrer"&gt;digital-strategy.ec.europa.eu/en/policies/cra-reporting&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Large companies have legal and compliance staff for this. Small teams have whoever is on call. The deadlines count in hours, weekends included, so the runbook has to exist before the report arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write down who decides that a vulnerability is "actively exploited."&lt;/li&gt;
&lt;li&gt;Record the moment you became aware.&lt;/li&gt;
&lt;li&gt;Open a tracked issue that computes the 24h and 72h deadlines.&lt;/li&gt;
&lt;li&gt;Prepare the early-warning and notification content as templates, so submitting is a copy-and-paste job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This applies if you place products with digital elements on the EU market. Verify scope with counsel, because this article is engineering guidance, not legal advice.&lt;/p&gt;

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

&lt;p&gt;You need a few things in place before the runbook is worth anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A scope decision.&lt;/strong&gt; List which of your products are "products with digital elements" under the CRA. Pure hosted SaaS is generally handled differently, and open-source stewards have lighter obligations. Check the regulation text (Regulation (EU) 2024/2847) and the Commission page above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access to the EU single reporting platform.&lt;/strong&gt; The CRA routes reports through a single reporting platform established by ENISA. Submissions reach the CSIRT designated as coordinator and ENISA. Check the Commission page for current onboarding steps, and get your account created before an incident, not during one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two named people&lt;/strong&gt;, a decider and a submitter, each with a deputy. One person cannot cover a 72-hour window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An SBOM per release&lt;/strong&gt;, and the &lt;code&gt;gh&lt;/code&gt; CLI authenticated with issue-write rights (&lt;a href="https://cli.github.com/manual/" rel="noopener noreferrer"&gt;cli.github.com/manual&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tooling:&lt;/strong&gt; GNU &lt;code&gt;date&lt;/code&gt; (Linux, or &lt;code&gt;gdate&lt;/code&gt; on macOS), plus &lt;code&gt;jq&lt;/code&gt;, &lt;code&gt;syft&lt;/code&gt; and &lt;code&gt;grype&lt;/code&gt; for the evidence step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cost to be aware of: this adds on-call duty and process weight. If you ship nothing to the EU market, skip it.&lt;/p&gt;

&lt;h2&gt;Step 1: Make intake findable and define "aware"&lt;/h2&gt;



&lt;p&gt;The clock runs from when you become aware of active exploitation, so the first job is a reachable intake channel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Publish a &lt;code&gt;SECURITY.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Publish a &lt;code&gt;/.well-known/security.txt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Enable GitHub's private vulnerability reporting (see &lt;a href="https://docs.github.com/en/code-security/security-advisories" rel="noopener noreferrer"&gt;GitHub security advisories docs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal &lt;code&gt;security.txt&lt;/code&gt; served at &lt;code&gt;https://example.com/.well-known/security.txt&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Contact: mailto:security@example.com
Expires: 2027-06-30T00:00:00.000Z
Policy: https://example.com/security-policy
Preferred-Languages: en
Canonical: https://example.com/.well-known/security.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then put a definition in &lt;code&gt;docs/cra-runbook.md&lt;/code&gt;. For example: "Awareness time is the timestamp when the decider records that credible evidence of exploitation exists (a vendor report, telemetry, a public exploit used in the wild), not when the first email arrived."&lt;/p&gt;

&lt;p&gt;Whether that definition holds up legally is a question for your counsel. What matters operationally is that it is written down and timestamped, so nobody argues about it at 3 a.m.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; an unmonitored &lt;code&gt;security@&lt;/code&gt; mailbox. If a report sits unread over a weekend, ask counsel whether that delay could count against you. Do not assume the clock waits for you to read it. Route the mailbox to the same pager as production alerts.&lt;/p&gt;

&lt;h2&gt;Step 2: Start the clock with one command&lt;/h2&gt;

&lt;p&gt;The cheapest control is a script that stamps the awareness time and creates a tracked issue with both deadlines. The labels must exist first:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;gh label create cra-clock
gh label create sev-exploited
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The script uses epoch arithmetic rather than date-string parsing, so the offsets are always exactly 24 and 72 hours.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/usr/bin/env bash
# cra-clock.sh &amp;lt;product&amp;gt; &amp;lt;cve-or-internal-id&amp;gt;
set -euo pipefail
PRODUCT="${1:?product}"; VULN="${2:?cve or internal id}"

FMT='+%Y-%m-%dT%H:%M:%SZ'
AWARE_EPOCH=$(date -u +%s)
AWARE=$(date -u -d "@${AWARE_EPOCH}" "$FMT")
T24=$(date -u -d "@$((AWARE_EPOCH + 86400))" "$FMT")    # early warning due
T72=$(date -u -d "@$((AWARE_EPOCH + 259200))" "$FMT")   # notification due

gh issue create \
  --title "CRA clock: ${VULN} (${PRODUCT})" \
  --label cra-clock --label sev-exploited \
  --assignee "@me" \
  --body "$(cat &amp;lt;&amp;lt;EOF
**Aware at:** ${AWARE}
**Early warning due (24h):** ${T24}
**Notification due (72h):** ${T72}

- [ ] Decider confirmed evidence of exploitation
- [ ] Member states where product is available listed
- [ ] Early warning submitted (record reference ID)
- [ ] Notification drafted: product, nature of vuln/exploit, mitigations
- [ ] Notification submitted (record reference ID)
- [ ] Corrective measure released; final report due within 14 days of that (confirm in Art. 14)
EOF
)"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;An alias worth keeping in your shell profile: &lt;code&gt;alias craclock='~/ops/cra-clock.sh'&lt;/code&gt;. Starting the clock takes seconds. GitHub records the issue's creation time, and any later edits to the body appear in the issue's edit history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; macOS ships BSD &lt;code&gt;date&lt;/code&gt;, which does not support &lt;code&gt;-d&lt;/code&gt;. Use &lt;code&gt;gdate&lt;/code&gt; from coreutils, or the script will fail at the worst moment.&lt;/p&gt;

&lt;h2&gt;Step 3: Gather evidence and pre-write the reports&lt;/h2&gt;

&lt;p&gt;The early warning is meant to be fast and thin. For an actively exploited vulnerability, the regulation asks it to indicate, where applicable, the member states in which you know the product has been made available. (The "suspected malicious act" indication applies to severe-incident early warnings.)&lt;/p&gt;

&lt;p&gt;The 72h notification adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;general product information&lt;/li&gt;
&lt;li&gt;the general nature of the vulnerability and exploit&lt;/li&gt;
&lt;li&gt;the corrective or mitigating measures taken&lt;/li&gt;
&lt;li&gt;the measures users can take&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep one Markdown template for each report in the repo, and fill in the blanks rather than composing prose under pressure.&lt;/p&gt;

&lt;p&gt;For evidence, generate an SBOM of the artifact you actually ship and cross-check it against CISA's Known Exploited Vulnerabilities catalog. Grype often reports GitHub advisory (GHSA) IDs, so the filter also reads the related CVE IDs:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;syft dir:./release -o cyclonedx-json=sbom.cdx.json            # SBOM of the shipped artifact
grype sbom:./sbom.cdx.json -o json &amp;gt; findings.json            # match components to vulns

curl -fsSL https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
  | jq -r '.vulnerabilities[].cveID' | LC_ALL=C sort -u &amp;gt; kev.txt

jq -r '.matches[] | .vulnerability.id, (.relatedVulnerabilities[]?.id)' findings.json \
  | grep '^CVE-' | LC_ALL=C sort -u \
  | LC_ALL=C comm -12 - kev.txt                                 # CVEs in your SBOM AND in KEV
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Each output line is a CVE in your shipped components that is known to be exploited somewhere. Treat that as a strong trigger for the decider, not as the legal definition. KEV is one signal; your own telemetry and customer reports count too, and absence from KEV proves nothing.&lt;/p&gt;

&lt;p&gt;Verify the flags against the current Syft and Grype documentation, since both tools change output formats between releases.&lt;/p&gt;

&lt;h2&gt;Step 4: Assign roles and hand-offs across the 72 hours&lt;/h2&gt;

&lt;p&gt;Write a simple timeline into the runbook so tired people are not improvising:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hour 0:&lt;/strong&gt; run &lt;code&gt;craclock&lt;/code&gt; as soon as a credible report arrives. The decider then confirms or rejects the exploitation evidence and notes the reason in the issue. Starting at triage is deliberately conservative: it can only make your deadlines earlier than the legal ones. If the evidence is rejected, close the issue with the reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hours 0-8:&lt;/strong&gt; the submitter lists affected member states and files the early warning. Aim to finish well inside 24 hours, since the platform may be slow or your login may have expired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hours 8-60:&lt;/strong&gt; engineering builds a mitigation (config change, feature flag, patch). Draft the notification in parallel, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hours 60-72:&lt;/strong&gt; the submitter files the notification and links the reference IDs back in the issue. Do not wait for the full fix; the notification describes measures taken so far.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After a corrective or mitigating measure is available, a final report is due. Article 14 sets this at no later than 14 days for actively exploited vulnerabilities; confirm against the regulation text and put a dated reminder in the issue.&lt;/p&gt;

&lt;p&gt;Keep users informed too. The CRA expects you to tell impacted users about the vulnerability and what they can do about it.&lt;/p&gt;

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

&lt;p&gt;Run a drill before you need one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clock script.&lt;/strong&gt; From a clone of a test repo, run &lt;code&gt;./cra-clock.sh demo-product TEST-0001&lt;/code&gt;. Confirm the issue appears with the two timestamps exactly 24 and 72 hours after "Aware at." If the script errors, the cause is almost always a non-GNU &lt;code&gt;date&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence pipeline.&lt;/strong&gt; Build a throwaway fixture that contains a component at a version affected by a KEV-listed CVE, for example &lt;code&gt;log4j-core&lt;/code&gt; 2.14.1 (CVE-2021-44228). Confirm the &lt;code&gt;comm -12&lt;/code&gt; line prints that CVE. An empty result against a deliberately vulnerable fixture means the pipeline is broken, not that you are safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tabletop exercise.&lt;/strong&gt; Time the elapsed time from &lt;code&gt;craclock&lt;/code&gt; to a filled-in early-warning draft; the target is hours, not a day. Log in to the reporting platform during the drill, but do not submit real reports for fake incidents.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Repeat the drill after staff changes, and calendar the &lt;code&gt;security.txt&lt;/code&gt; expiry date.&lt;/p&gt;

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

&lt;p&gt;CRA vulnerability reporting rewards boring preparation: a monitored inbox, a written definition of awareness, a one-command clock script, and templates that turn a Friday-night scramble into a checklist.&lt;/p&gt;

&lt;p&gt;None of this replaces legal review of your scope and obligations. A small team should confirm both with the Commission's guidance and the regulation text. Set it up this week, rehearse it once, and put the platform login in your password manager. For more practical security and CI/CD runbooks, browse &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/11/02/jenkins-agent-hardening-lock-down-build-execution/" rel="noopener noreferrer"&gt;Jenkins Agent Hardening: Lock Down Build Execution&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/11/01/kubernetes-networkpolicy-baseline-3-mistakes-that-break-isolation/" rel="noopener noreferrer"&gt;Kubernetes NetworkPolicy Baseline: 3 Mistakes That Break Isolation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/30/wireguard-key-rotation-checklist-for-multi-peer-setups/" rel="noopener noreferrer"&gt;WireGuard Key Rotation Checklist for Multi-Peer Setups&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>PostgreSQL 18 Asynchronous I/O: Settings Worth Testing</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Thu, 05 Nov 2026 08:01:50 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/postgresql-18-asynchronous-io-settings-worth-testing-5cih</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/postgresql-18-asynchronous-io-settings-worth-testing-5cih</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/11/05/postgresql-18-asynchronous-i-o-settings-worth-testing" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A reporting replica gets upgraded to the new major version. Someone reads a headline about "async I/O making scans faster," and a ticket appears: "turn on whatever gives us the speedup." Nobody can say which knob that is, or whether the workload even does the kind of reads that benefit. That is the normal state of &lt;strong&gt;PostgreSQL 18 asynchronous I/O&lt;/strong&gt; adoption: a real feature with vague expectations.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.postgresql.org/docs/18/release-18.html" rel="noopener noreferrer"&gt;PostgreSQL 18 release notes&lt;/a&gt; introduce an asynchronous I/O subsystem. PostgreSQL 18 is the current stable line (18.6 at the time of writing). The feature matters now because it is active after the upgrade with its default settings, so you are already running it whether you tuned it or not. The useful question is which few settings deserve a controlled test on your data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; keep the default &lt;code&gt;io_method = worker&lt;/code&gt; unless two conditions hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are on Linux with a kernel and PostgreSQL build that support &lt;code&gt;io_uring&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your queries are read-heavy with cold or large working sets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If both hold, A/B test &lt;code&gt;worker&lt;/code&gt; against &lt;code&gt;io_uring&lt;/code&gt;, then &lt;code&gt;io_workers&lt;/code&gt;, then &lt;code&gt;effective_io_concurrency&lt;/code&gt;, one change at a time. If your hot data fits in &lt;code&gt;shared_buffers&lt;/code&gt; and the OS cache, expect little or nothing.&lt;/p&gt;

&lt;h2&gt;What PostgreSQL 18 asynchronous I/O actually does&lt;/h2&gt;



&lt;p&gt;Before 18, a backend asked the kernel for a block and waited. Read-ahead and &lt;code&gt;posix_fadvise&lt;/code&gt; hints helped in places, but reads were still synchronous from the backend's point of view. With the new subsystem, a backend can submit several read requests and continue working while they are in flight.&lt;/p&gt;

&lt;p&gt;Two things decide how that happens. The first is the &lt;code&gt;io_method&lt;/code&gt; setting, which picks the mechanism:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;worker&lt;/code&gt; (the default) hands reads to dedicated I/O worker processes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;io_uring&lt;/code&gt; uses the Linux kernel's ring interface directly from the backend.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sync&lt;/code&gt; keeps the old blocking behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second is the set of code paths that use it. As documented for 18, the main beneficiaries are sequential scans, bitmap heap scans and vacuum. Writes and WAL are not part of this change, so write-heavy OLTP gains little. Verify the exact scope in the release notes.&lt;/p&gt;

&lt;p&gt;Related defaults also moved: &lt;code&gt;effective_io_concurrency&lt;/code&gt; and &lt;code&gt;maintenance_io_concurrency&lt;/code&gt; were raised in 18 (check with &lt;code&gt;SHOW&lt;/code&gt; on your build). In practice, an upgraded server behaves differently before you touch anything. That is why a before/after comparison on identical queries matters.&lt;/p&gt;

&lt;h2&gt;How people use it wrong&lt;/h2&gt;

&lt;p&gt;The most common error is copying a tuning snippet from a blog post and restarting production with several changed values. Then nobody knows which value helped, hurt, or did nothing. One plausible failure mode is a latency regression caused by oversized worker counts competing with query backends for CPU.&lt;/p&gt;

&lt;p&gt;The second error is benchmarking on a warm cache. If the table is already in &lt;code&gt;shared_buffers&lt;/code&gt; or the page cache, there is almost no read I/O to overlap, and every method looks identical. The reverse also happens: someone drops caches once, sees a gain, and treats it as steady-state behavior for a workload that is mostly cached.&lt;/p&gt;

&lt;p&gt;Third, people assume &lt;code&gt;io_uring&lt;/code&gt; is simply "the fast one." It depends on Linux, a PostgreSQL build with liburing support (&lt;code&gt;--with-liburing&lt;/code&gt;), and a kernel that allows it. &lt;strong&gt;Watch out for container runtimes:&lt;/strong&gt; default seccomp profiles and some hardened hosts restrict the io_uring syscalls. A setting that works on a VM can therefore fail inside a container. Check your runtime's documentation rather than assuming.&lt;/p&gt;

&lt;p&gt;Finally, people expect gains on index-driven point lookups, write-heavy ingestion or WAL-bound workloads. The subsystem in 18 does not target those paths, so no setting will fix them.&lt;/p&gt;

&lt;h2&gt;The correct approach&lt;/h2&gt;

&lt;p&gt;Treat it as an experiment with one variable at a time. First, record your baseline: the current values and a representative set of slow read queries. Run the first block below on the target version to capture the starting point.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;-- Baseline: current AIO-related settings (values shown are whatever your build uses)
SELECT name, setting, unit, context
FROM pg_settings
WHERE name IN ('io_method','io_workers','io_max_concurrency',
               'io_combine_limit','effective_io_concurrency',
               'maintenance_io_concurrency','track_io_timing');

-- Confirm I/O worker processes exist when io_method = worker
SELECT pid, backend_type
FROM pg_stat_activity
WHERE backend_type = 'io worker';
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Next, enable timing so the numbers mean something, and write a candidate config. &lt;code&gt;io_method&lt;/code&gt; and &lt;code&gt;io_max_concurrency&lt;/code&gt; need a restart, while &lt;code&gt;io_workers&lt;/code&gt; can be changed with a reload (confirm with the &lt;code&gt;context&lt;/code&gt; column above). Put changes in a drop-in file so they are easy to revert.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# /etc/postgresql/18/main/conf.d/aio-test.conf  (path varies by distro)
track_io_timing = on          # adds I/O time to EXPLAIN and pg_stat_io
io_method = io_uring          # candidate; baseline run used the default 'worker'
# io_workers = 6              # only relevant for io_method = worker; test separately
# effective_io_concurrency = 32   # change in a later, separate round
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Run the same queries with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS, SETTINGS)&lt;/code&gt; against a cold cache on a non-production host. For a cold start, restart PostgreSQL and clear the OS page cache (&lt;code&gt;sync; echo 3 &amp;gt; /proc/sys/vm/drop_caches&lt;/code&gt; as root, test machine only). Repeat each case several times and compare medians, not the best run.&lt;/p&gt;

&lt;p&gt;To verify the change took effect, &lt;code&gt;SHOW io_method;&lt;/code&gt; must return the new value after restart, and the server log must show a clean start. If &lt;code&gt;io_uring&lt;/code&gt; cannot be used, the server refuses to start rather than silently falling back. This happens either because the build lacks liburing, which makes the value invalid, or because the kernel or seccomp blocks the syscalls. You find out immediately, which is another reason to test this on a non-production host first.&lt;/p&gt;

&lt;h2&gt;Advanced patterns&lt;/h2&gt;

&lt;p&gt;Once the method is chosen, tune concurrency against your storage. Low-latency local NVMe often reaches its useful limit with less concurrency than network storage, but measure rather than assume. On network-attached volumes such as cloud block storage, latency dominates, and more outstanding requests usually pay off up to the volume's IOPS or throughput cap. That cap is the real ceiling, so check it before concluding that PostgreSQL is the bottleneck.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;worker&lt;/code&gt; mode, size &lt;code&gt;io_workers&lt;/code&gt; against CPU headroom. Each worker is a process, and on a busy host the workers compete with query backends. Increase the count in small steps and watch whether scan time improves or the CPU run queue grows instead.&lt;/p&gt;

&lt;p&gt;Maintenance work is a separate test. Vacuum reads are governed by &lt;code&gt;maintenance_io_concurrency&lt;/code&gt;, so vacuuming a bloated table on a staging copy is a clean way to measure it without touching query latency. Pair the test with whatever bloat monitoring you already run; more PostgreSQL operations material is on &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For live observation, sample the in-flight view while a test query runs. &lt;code&gt;pg_aios&lt;/code&gt; requires superuser or membership in &lt;code&gt;pg_read_all_stats&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;-- Run repeatedly (e.g. with \watch 1) during a scan; empty output means nothing is in flight
SELECT * FROM pg_aios;

-- Read volume and time per backend type, since stats reset
SELECT backend_type, object, context, reads, read_bytes, read_time
FROM pg_stat_io
WHERE reads &amp;gt; 0
ORDER BY read_time DESC NULLS LAST;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If you export &lt;code&gt;pg_stat_io&lt;/code&gt; to Prometheus and graph it in Grafana, record the delta of &lt;code&gt;read_time&lt;/code&gt; and &lt;code&gt;reads&lt;/code&gt; per test window. Average time per read is a better signal than raw totals. Column sets differ between major versions (18 added byte columns such as &lt;code&gt;read_bytes&lt;/code&gt;), so verify exporter queries against the &lt;a href="https://www.postgresql.org/docs/18/" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;Performance notes&lt;/h2&gt;

&lt;p&gt;Expect uneven results. Large sequential scans over data not in cache are the likeliest winners. Queries that are CPU-bound after the read, such as heavy aggregation or expression evaluation, stay limited by the CPU; faster I/O only exposes that. Highly selective index lookups mostly will not move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; changing &lt;code&gt;io_combine_limit&lt;/code&gt; alters the size of individual read requests, which interacts with storage block sizes and OS read-ahead. Leave it at the default unless a test shows a specific reason. Never edit developer-only options such as &lt;code&gt;debug_io_direct&lt;/code&gt; on a real system.&lt;/p&gt;

&lt;p&gt;The costs are modest but real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;io_uring&lt;/code&gt; adds a kernel and build dependency and may complicate container hardening.&lt;/li&gt;
&lt;li&gt;Extra workers consume CPU and memory.&lt;/li&gt;
&lt;li&gt;Higher concurrency can let one noisy analytical query take a larger share of shared storage bandwidth, which hurts neighbors on a shared volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your replica serves mixed traffic, test with the real mix running.&lt;/p&gt;

&lt;p&gt;When not to bother: small databases that fit in RAM, write-dominated systems, or any server where the upgrade is already stable and the queries meet their SLOs. Defaults in 18 are chosen to be safe, and "no change" is a valid outcome of the experiment.&lt;/p&gt;

&lt;p&gt;A simple decision rule to keep:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If cold-cache &lt;code&gt;EXPLAIN&lt;/code&gt; shows significant I/O time, change one setting.&lt;/li&gt;
&lt;li&gt;Rerun the identical query set.&lt;/li&gt;
&lt;li&gt;Keep the change only if the median improves without raising CPU or p99 latency elsewhere.&lt;/li&gt;
&lt;li&gt;Write the result next to the config file so the next engineer does not repeat the test.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/25/postgresql-vacuum-monitoring-stop-table-bloat-before-it-hurts/" rel="noopener noreferrer"&gt;PostgreSQL VACUUM Monitoring: Stop Table Bloat Before It Hurts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/09/postgres-backup-cronjob-on-kubernetes-setup-and-restore-testing/" rel="noopener noreferrer"&gt;Postgres Backup CronJob on Kubernetes: Setup and Restore Testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/08/29/postgresql-vacuum-monitoring-catch-bloat-before-it-causes-downtime/" rel="noopener noreferrer"&gt;PostgreSQL VACUUM Monitoring: Catch Bloat Before It Causes Downtime&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>What changed in Argo CD 3.5 for ops teams upgrading from 3.x</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:01:49 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/what-changed-in-argo-cd-35-for-ops-teams-upgrading-from-3x-2nmb</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/what-changed-in-argo-cd-35-for-ops-teams-upgrading-from-3x-2nmb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/11/04/what-changed-in-argo-cd-3-5-for-ops-teams-upgrading-from-3-x" 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;A platform team runs Argo CD 3.2 or 3.3 in a shared cluster, with a few hundred Applications, and the ticket says "get to Argo CD 3.5 before the next audit". &lt;strong&gt;Argo CD 3.5 was released in August 2026&lt;/strong&gt; (see the &lt;a href="https://github.com/argoproj/argo-cd/releases" rel="noopener noreferrer"&gt;official releases page&lt;/a&gt;), and the cycle has since collected patch releases up to 3.5.6. That matters now because the upstream support policy covers only the newest minor lines, and every month you wait widens the gap you have to cross.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; do not treat this as a version bump in a manifest. Back up the Argo CD state, read the upgrade notes for every minor between your version and 3.5, rehearse on a non-production instance with a pinned 3.5.x manifest, then promote and watch sync metrics. This applies if you are on any 3.x minor; if you are still on 2.x, this is a different, bigger migration.&lt;/p&gt;

&lt;p&gt;One caveat up front: this article does not reproduce the 3.5 changelog line by line. The authoritative list of behavior changes lives in the upgrade pages of the &lt;a href="https://argo-cd.readthedocs.io/en/stable/" rel="noopener noreferrer"&gt;Argo CD documentation&lt;/a&gt; (operator manual, "Upgrading" section) and the release notes. What follows is the process that turns that list into a safe rollout.&lt;/p&gt;

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

&lt;p&gt;Before touching anything, make sure you have the following in place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sufficient Kubernetes permissions&lt;/strong&gt;: admin rights in the namespace running Argo CD (assumed &lt;code&gt;argocd&lt;/code&gt; below), plus cluster-scoped permission to apply CRDs and cluster roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The argocd CLI&lt;/strong&gt; at a version matching the server you are targeting. A CLI that is several minors behind can behave oddly against a newer API server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A non-production Argo CD&lt;/strong&gt; with a representative slice of Applications, ApplicationSets and your real &lt;code&gt;argocd-cm&lt;/code&gt; / &lt;code&gt;argocd-rbac-cm&lt;/code&gt; settings. A vanilla install tells you little.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your install method written down&lt;/strong&gt;: raw upstream manifests (standard or HA), Kustomize overlay, or the community Helm chart. Each pins versions differently, and a chart version is not the same number as the app version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Kubernetes compatibility check&lt;/strong&gt;. If your clusters are moving to Kubernetes 1.37, confirm the tested-versions information in the Argo CD docs for your target release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Watch out for&lt;/strong&gt; exposure through ingress-nginx. That controller was retired in March 2026 and gets no further security fixes. If &lt;code&gt;argocd-server&lt;/code&gt; sits behind it, plan the move to Gateway API or another maintained controller as a separate change. Do not do it in the same window as the Argo CD upgrade, because mixing the two makes any login or gRPC failure ambiguous.&lt;/p&gt;

&lt;h2&gt;Step 1: Snapshot the current state&lt;/h2&gt;



&lt;p&gt;You need two things to compare against later: a restorable backup of Argo CD's own configuration and a baseline of what every Application looks like today. The block below captures both.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Record the running versions (client and server)
argocd version

# Backup Applications, AppProjects, settings and secrets handled by Argo CD
argocd admin export -n argocd &amp;gt; argocd-backup-$(date +%F).yaml

# Baseline: name, sync status, health for every app
argocd app list -o json \
  | jq -r '.[] | [.metadata.name, .status.sync.status, .status.health.status] | @tsv' \
  | sort &amp;gt; baseline-apps.tsv

wc -l baseline-apps.tsv   # keep this count, you will compare it later
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Store the export somewhere encrypted, because it can contain repository credentials and cluster secrets. Also note any Applications that are already &lt;code&gt;OutOfSync&lt;/code&gt; or &lt;code&gt;Degraded&lt;/code&gt;. A typical failure during upgrades is blaming the new version for drift that existed beforehand.&lt;/p&gt;

&lt;h2&gt;Step 2: Read the notes for every minor and diff your config&lt;/h2&gt;

&lt;p&gt;Go to the operator manual's upgrading section and read each page between your current minor and 3.5, in order. For example, read 3.3 to 3.4, then 3.4 to 3.5. Upstream documents upgrades one minor at a time, and skipped notes are where defaults changed without you noticing.&lt;/p&gt;

&lt;p&gt;While reading, sort each item into three buckets: breaking changes that need a config edit, changed defaults that alter runtime behavior, and deprecations with a removal date. Then check your own settings against them. Look specifically at &lt;code&gt;argocd-cm&lt;/code&gt; (resource customizations, tracking method, exclusions), &lt;code&gt;argocd-cmd-params-cm&lt;/code&gt; (component flags), &lt;code&gt;argocd-rbac-cm&lt;/code&gt; (policies) and any config management plugins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for&lt;/strong&gt; resource tracking and ignore-difference settings. Changes in how Argo CD tracks or diffs resources are exactly the kind of thing that can flip hundreds of Applications to &lt;code&gt;OutOfSync&lt;/code&gt; after an otherwise clean upgrade. If a note touches either, test it on the staging instance first with &lt;code&gt;argocd app diff&lt;/code&gt; on several real Applications.&lt;/p&gt;

&lt;p&gt;Also review your pinned dependencies. The Helm and Kustomize versions bundled in the repo-server image can change between minors, which can alter rendered output for the same chart. Run &lt;code&gt;argocd admin settings --help&lt;/code&gt; to see the validation helpers your CLI version provides, and use them on your edited ConfigMaps.&lt;/p&gt;

&lt;h2&gt;Step 3: Rehearse on staging with a pinned manifest&lt;/h2&gt;

&lt;p&gt;Pin an exact patch version, never a moving tag like &lt;code&gt;stable&lt;/code&gt;. The documented way to handle the large Argo CD CRDs without annotation-size errors is to apply the upstream install manifest with server-side apply. Use the manifest that matches your current topology: &lt;code&gt;manifests/install.yaml&lt;/code&gt; for a standard install, &lt;code&gt;manifests/ha/install.yaml&lt;/code&gt; for HA. Applying the non-HA manifest over an HA install replaces the HA components.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Staging context first
kubectl config use-context staging

# Pin the patch release; v3.5.6 is the latest 3.5.x at time of writing
# (HA installs: use manifests/ha/install.yaml instead)
kubectl apply -n argocd --server-side --force-conflicts \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.6/manifests/install.yaml

# Wait for control plane components
kubectl -n argocd rollout status deploy/argocd-server
kubectl -n argocd rollout status deploy/argocd-repo-server
kubectl -n argocd rollout status statefulset/argocd-application-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If you use a Kustomize overlay or Helm chart, change the version there and render the diff first. For Kustomize, use &lt;code&gt;kustomize build . | kubectl diff --server-side -f -&lt;/code&gt;. For Helm, use &lt;code&gt;helm diff upgrade &amp;lt;release&amp;gt; &amp;lt;chart&amp;gt; --version &amp;lt;chart-version&amp;gt;&lt;/code&gt;, which requires the helm-diff plugin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; &lt;code&gt;--force-conflicts&lt;/code&gt; makes the apply take ownership of every field the upstream manifest sets, even where another field manager changed it. Any hand edits to those fields will be reset unless they live in your overlay; this covers container args, images and env made with &lt;code&gt;kubectl edit&lt;/code&gt; or &lt;code&gt;kubectl patch&lt;/code&gt;. Fields the manifest does not set are left alone. Review the diff for that before applying.&lt;/p&gt;

&lt;h2&gt;Step 4: Promote to production and keep a way back&lt;/h2&gt;

&lt;p&gt;Repeat the same command against production during a quiet window, and avoid doing it while a big release is mid-sync. The application controller restarts and re-reconciles everything, so expect a burst of repo-server and API-server load; for large installs this is the real cost of the upgrade.&lt;/p&gt;

&lt;p&gt;Your rollback plan is concrete. First, re-apply the previous pinned manifest. If state looks damaged, restore from the export with &lt;code&gt;argocd admin import -n argocd - &amp;lt; argocd-backup-YYYY-MM-DD.yaml&lt;/code&gt;. Be aware that CRD schema changes are not always cleanly reversible, so the rehearsal on staging is what actually de-risks this, not the rollback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When not to do this:&lt;/strong&gt; do not upgrade in the middle of an incident, a freeze, or while you cannot get a human to watch the first reconciliation. If you manage many clusters from one Argo CD, also consider upgrading a secondary instance first. The extra instance costs money and operational overhead, but limits blast radius.&lt;/p&gt;

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

&lt;p&gt;Start with versions and component health, then compare against the baseline you took in step 1.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;argocd version                      # server should report v3.5.6

argocd app list -o json \
  | jq -r '.[] | [.metadata.name, .status.sync.status, .status.health.status] | @tsv' \
  | sort &amp;gt; after-apps.tsv

diff baseline-apps.tsv after-apps.tsv &amp;amp;&amp;amp; echo "no status drift"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;An empty diff means every Application kept its sync and health status. Any lines that differ are your investigation list. Run &lt;code&gt;argocd app diff &amp;lt;name&amp;gt;&lt;/code&gt; on each to see whether the cause is rendering, tracking or a genuine change. Then test the full path with one throwaway Application: sync it, change a value in Git, and confirm that the change is detected and applied.&lt;/p&gt;

&lt;p&gt;For ongoing confidence, watch the Prometheus metrics Argo CD exposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;argocd_app_info&lt;/code&gt;, grouped by &lt;code&gt;sync_status&lt;/code&gt; and &lt;code&gt;health_status&lt;/code&gt;, should return to its pre-upgrade shape within a few reconcile cycles.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;argocd_app_sync_total&lt;/code&gt;, filtered by its &lt;code&gt;phase&lt;/code&gt; label, should show failed or errored syncs increasing only when you expect them.&lt;/li&gt;
&lt;li&gt;Pod restarts and repo-server logs are worth checking too. Repeated restarts after the first hour usually point to resource limits that need adjusting.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;An Argo CD 3.5 upgrade from 3.x is mostly a reading and rehearsal exercise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Snapshot with &lt;code&gt;argocd admin export&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Work through each minor's upgrade notes.&lt;/li&gt;
&lt;li&gt;Pin &lt;code&gt;v3.5.6&lt;/code&gt; or later, using the manifest that matches your install topology.&lt;/li&gt;
&lt;li&gt;Apply with server-side apply on staging.&lt;/li&gt;
&lt;li&gt;Compare application status before and after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the official documentation open while you do it, because defaults and flags are version-specific and anything in this article should be checked against it. For more runbooks on GitOps and platform tooling, browse &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/18/argo-cd-rollback-git-revert-vs-argocd-app-rollback/" rel="noopener noreferrer"&gt;Argo CD rollback: git revert vs argocd app rollback&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/01/gitops-with-gitpod-vs-local-dev-containers-which-to-pick/" rel="noopener noreferrer"&gt;GitOps with Gitpod vs Local Dev Containers: Which to Pick&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/08/25/argo-cd-canary-deployment-gating-rollouts-with-real-slis/" rel="noopener noreferrer"&gt;Argo CD Canary Deployment: Gating Rollouts with Real SLIs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>OpenTofu or Terraform: What to Pick for Existing Code</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Tue, 03 Nov 2026 08:01:59 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/opentofu-or-terraform-what-to-pick-for-existing-code-507l</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/opentofu-or-terraform-what-to-pick-for-existing-code-507l</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/11/03/opentofu-or-terraform-what-to-pick-for-existing-code" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The question of OpenTofu or Terraform tends to arrive on a quiet Tuesday. A repo with 200 resources, a remote state in S3 and a CI pipeline pinned to &lt;code&gt;terraform&lt;/code&gt; works fine, until someone asks whether the next upgrade should be to the other tool. Nothing is broken, so the real question is whether switching buys anything that justifies the risk.&lt;/p&gt;

&lt;p&gt;The timing is worth noting. Both projects shipped new minor cycles recently: OpenTofu 1.13 (latest patch 1.13.5, cycle released 2026-09-30, &lt;a href="https://github.com/opentofu/opentofu/releases" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;) and Terraform 1.17 (latest patch 1.17.1, cycle released 2026-10-21, &lt;a href="https://github.com/hashicorp/terraform/releases" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;). Anyone planning a quarterly upgrade has two fresh targets. The two projects follow independent roadmaps, so check each changelog for features that exist in only one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; if your code is plain AWS/GCP/Azure/Kubernetes resources and you have no contract with HCP Terraform, you can usually stay on Terraform 1.17 or move to OpenTofu 1.13 at low risk, so choose based on licensing and features. If you depend on HCP Terraform, Stacks or other HashiCorp-hosted services, stay on Terraform. If you want client-side state encryption or an open-governance licence, trial OpenTofu with the dry-run below before committing.&lt;/p&gt;

&lt;h2&gt;When the OpenTofu or Terraform choice actually matters&lt;/h2&gt;



&lt;p&gt;For a small repo with a single backend, the choice is mostly cosmetic. It starts to matter in four situations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Licensing review.&lt;/strong&gt; Terraform releases after 1.5.x use the Business Source License; OpenTofu is MPL-2.0 under the Linux Foundation. Legal teams sometimes care, particularly if you ship tooling to customers. Read the current licence text yourself rather than relying on a blog summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State sensitivity.&lt;/strong&gt; State holds secrets in plain text unless the backend encrypts it. OpenTofu has built-in state and plan encryption. Terraform relies on backend encryption and on its ephemeral and write-only mechanisms to keep values out of state. Check the Terraform docs for what your version supports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform coupling.&lt;/strong&gt; If your org runs workspaces, policies and run tasks in HCP Terraform or Terraform Enterprise, the tool and the platform are one decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature divergence.&lt;/strong&gt; Since the 2023 fork the two projects have added different features and, in some cases, different syntax for similar ideas. Config that uses a tool-specific feature will not run on the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither tool has a blanket advantage. Compare the changelogs for your specific version jump, not for the headline releases.&lt;/p&gt;

&lt;h2&gt;Option A: OpenTofu 1.13&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open governance and an MPL-2.0 licence, which simplifies procurement and redistribution questions.&lt;/li&gt;
&lt;li&gt;Native state and plan encryption with pluggable key providers (including AWS KMS), configured in the &lt;code&gt;terraform&lt;/code&gt; block. This matters when state lands in shared buckets.&lt;/li&gt;
&lt;li&gt;Features that landed after the fork, such as &lt;code&gt;for_each&lt;/code&gt; on providers and the &lt;code&gt;-exclude&lt;/code&gt; flag, are documented on &lt;a href="https://opentofu.org/docs/" rel="noopener noreferrer"&gt;opentofu.org&lt;/a&gt;. Confirm what is in 1.13 against its changelog.&lt;/li&gt;
&lt;li&gt;Same HCL, same provider protocol, same workflow. The binary is &lt;code&gt;tofu&lt;/code&gt; and the commands match, so the CLI is familiar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Migration becomes effectively one-way once you encrypt state or adopt OpenTofu-only syntax. Terraform cannot read encrypted state, so going back means decrypting with OpenTofu or reverting the config first.&lt;/li&gt;
&lt;li&gt;Provider and module sourcing goes through the OpenTofu registry. Your &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; entries will change, and any tooling hard-coded to &lt;code&gt;registry.terraform.io&lt;/code&gt; needs a check.&lt;/li&gt;
&lt;li&gt;Third-party integrations (scanners, IDE plugins, CI templates) vary in how well they support &lt;code&gt;tofu&lt;/code&gt;. Test yours rather than assuming.&lt;/li&gt;
&lt;li&gt;Commercial support comes from vendors rather than the original authors, which may or may not matter to your org.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Option B: Terraform 1.17&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero migration. Your pipeline, lock files, runbooks and team habits stay as they are, and upgrades inside the 1.x line follow the documented &lt;a href="https://developer.hashicorp.com/terraform" rel="noopener noreferrer"&gt;upgrade guides&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;First-party integration with HCP Terraform and Terraform Enterprise, including features that exist only there (Stacks, hosted policy and drift tooling). HashiCorp is now part of IBM, so vendor support contracts are a single conversation for many enterprises.&lt;/li&gt;
&lt;li&gt;Most vendor docs and provider examples still assume &lt;code&gt;terraform&lt;/code&gt;, so troubleshooting is easier to search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The BSL licence restricts offering a competing product. Most internal use is fine, but verify the terms against your situation, especially if you resell infrastructure automation.&lt;/li&gt;
&lt;li&gt;No built-in client-side state encryption. You depend on backend-side encryption (for example S3 with KMS) and on keeping secrets out of state in the first place.&lt;/li&gt;
&lt;li&gt;Roadmap and licence decisions sit with one vendor. That is a trade-off, not a defect, but it is a dependency you should name.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Decision matrix&lt;/h2&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;Lean toward&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HCP Terraform / Enterprise in daily use&lt;/td&gt;
&lt;td&gt;Terraform 1.17&lt;/td&gt;
&lt;td&gt;Platform features are tied to the vendor CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-managed CI with S3/GCS backend&lt;/td&gt;
&lt;td&gt;Either&lt;/td&gt;
&lt;td&gt;Low switching cost; decide on licence and features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need encrypted state independent of the backend&lt;/td&gt;
&lt;td&gt;OpenTofu 1.13&lt;/td&gt;
&lt;td&gt;Built-in encryption block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legal requires an OSI-approved licence&lt;/td&gt;
&lt;td&gt;OpenTofu 1.13&lt;/td&gt;
&lt;td&gt;MPL-2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy use of vendor-supported Terraform tooling&lt;/td&gt;
&lt;td&gt;Terraform 1.17&lt;/td&gt;
&lt;td&gt;Fewer integration surprises&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config written for a much older Terraform (1.5.x or earlier)&lt;/td&gt;
&lt;td&gt;Upgrade first, decide later&lt;/td&gt;
&lt;td&gt;Big version jumps are the risk, not the tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small team, no pain today&lt;/td&gt;
&lt;td&gt;Stay put&lt;/td&gt;
&lt;td&gt;Switching has a cost and no immediate payoff&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; do not combine a tool switch with a major provider upgrade in one change. When a plan shows unexpected diffs, you want exactly one variable to blame.&lt;/p&gt;

&lt;h2&gt;Evidence-based recommendation&lt;/h2&gt;

&lt;p&gt;The evidence for an urgent move in either direction is thin. Both tools plan and apply the same core HCL against the same providers. The differences that matter are licence, state handling, and platform lock-in, and you can evaluate those without touching production.&lt;/p&gt;

&lt;p&gt;So the practical approach is a read-only trial. The following runs OpenTofu against a copy of your current setup. It takes a state backup first, then produces a plan and never applies. Run it from a branch.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# 1. Back up current state with the tool you use today
terraform state pull &amp;gt; ../backup-$(date +%F).tfstate

# 2. Check versions; OpenTofu evaluates required_version against its own
#    version number, so review any terraform { required_version } constraint
terraform version
tofu version

# 3. Re-init with OpenTofu (rewrites .terraform.lock.hcl; review the diff)
rm -rf .terraform
tofu init
tofu providers lock -platform=linux_amd64 -platform=darwin_arm64

# 4. Read-only comparison: -lock=false avoids blocking teammates; never apply here
tofu plan -lock=false -detailed-exitcode
echo "exit code: $?"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;To verify the result, check the exit code. &lt;code&gt;0&lt;/code&gt; means no changes, which is what you want from an unchanged codebase. &lt;code&gt;2&lt;/code&gt; means a diff, so read it before you do anything else. &lt;code&gt;1&lt;/code&gt; is an error, typically a provider source or backend setting. If the plan is clean, run the same steps in CI on a non-production workspace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; run &lt;code&gt;tofu apply&lt;/code&gt; only on a workspace you can afford to roll back. A newer tool can write state that the older one refuses to read, and the backup from step 1 is your way out.&lt;/p&gt;

&lt;p&gt;If the trial is clean and you want OpenTofu's encryption, add it deliberately as a separate change. Existing state is unencrypted, so the first configuration needs an unencrypted fallback to read it. This example uses AWS KMS; the key alias is a placeholder for your own.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# encryption.tf (OpenTofu only; Terraform will reject this block)
terraform {
  encryption {
    key_provider "aws_kms" "main" {
      kms_key_id = "alias/tofu-state"   # your KMS key alias
      region     = "eu-west-1"
      key_spec   = "AES_256"
    }

    method "aes_gcm" "default" {
      keys = key_provider.aws_kms.main
    }

    # Temporary: lets OpenTofu read the existing unencrypted state
    method "unencrypted" "migrate" {}

    state {
      method = method.aes_gcm.default
      fallback {
        method = method.unencrypted.migrate
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;After the next &lt;code&gt;tofu apply&lt;/code&gt; writes encrypted state, remove the &lt;code&gt;unencrypted&lt;/code&gt; method and the &lt;code&gt;fallback&lt;/code&gt; block, and add &lt;code&gt;enforced = true&lt;/code&gt; inside &lt;code&gt;state&lt;/code&gt; so OpenTofu refuses to write unencrypted state. Verify by reading the raw object from the backend rather than through &lt;code&gt;tofu&lt;/code&gt;, which decrypts transparently, for example &lt;code&gt;aws s3 cp s3://&amp;lt;your-state-bucket&amp;gt;/&amp;lt;your-state-key&amp;gt; - | head -c 200&lt;/code&gt;. Encrypted state is a JSON envelope with encryption metadata instead of readable resource attributes. Details, plan encryption and rollback options are in the &lt;a href="https://opentofu.org/docs/" rel="noopener noreferrer"&gt;OpenTofu documentation&lt;/a&gt;, so follow those rather than improvising.&lt;/p&gt;

&lt;p&gt;The recommendation, with conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stay on Terraform 1.17&lt;/strong&gt; if you use HCP Terraform or Enterprise, or if nothing in the matrix hurts today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move to OpenTofu 1.13&lt;/strong&gt; if licence terms or state encryption are real requirements, and the dry-run above shows a clean plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do neither yet&lt;/strong&gt; if you are several minor versions behind. Upgrade within your current tool first, then compare.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For related infrastructure patterns, browse more guides on &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;. Whichever way you go, pin the binary version in CI so the choice stays a decision and never becomes drift.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/17/terraform-vault-secrets-keep-them-out-of-state-and-git/" rel="noopener noreferrer"&gt;Terraform Vault Secrets: Keep Them Out of State and Git&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/10/terraform-modules-for-aws-vpc-iam-and-s3-structure-that-scales/" rel="noopener noreferrer"&gt;Terraform Modules for AWS VPC, IAM, and S3: Structure That Scales&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/26/why-an-llm-reviewer-misses-risky-terraform-plan-changes/" rel="noopener noreferrer"&gt;Why an LLM reviewer misses risky Terraform plan changes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Jenkins Agent Hardening: Lock Down Build Execution</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:02:17 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/jenkins-agent-hardening-lock-down-build-execution-5ao3</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/jenkins-agent-hardening-lock-down-build-execution-5ao3</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/11/02/jenkins-agent-hardening-lock-down-build-execution" 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;A pull request from a contributor changes a &lt;code&gt;Jenkinsfile&lt;/code&gt;, and the build runs on a long-lived agent that also builds your release branch. Nothing in the pipeline looks malicious. However, the agent has a Docker socket mounted, a cloud credentials file in the home directory, and a workspace left over from the last job. Jenkins agent hardening exists to make that situation boring. A build can run arbitrary code, so the machine running it should have nothing worth stealing and no way to reach beyond its own job.&lt;/p&gt;

&lt;p&gt;The Jenkins project documents the controller/agent trust model in its &lt;a href="https://www.jenkins.io/doc/book/security/controller-isolation/" rel="noopener noreferrer"&gt;controller isolation guide&lt;/a&gt;. The short version is that anyone who can change a pipeline can execute code on an agent, so agents are a trust boundary. This guide applies that to four concrete areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keeping builds off the controller&lt;/li&gt;
&lt;li&gt;shrinking agent privileges&lt;/li&gt;
&lt;li&gt;confining permanent agents&lt;/li&gt;
&lt;li&gt;scoping secrets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The examples assume Kubernetes-based ephemeral agents plus one class of permanent Linux agents. Adjust them to your environment, and check the Jenkins and plugin documentation for version-specific behavior.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A current Jenkins LTS controller reachable over HTTPS. Agent-to-controller access rules have been always-on since Jenkins 2.326, so there is no legacy toggle to disable.&lt;/li&gt;
&lt;li&gt;Administrator access, ideally with Configuration as Code (JCasC) so the settings below are reviewable in Git.&lt;/li&gt;
&lt;li&gt;The Kubernetes plugin and a cluster where you can create pods in a dedicated namespace (for the ephemeral agents).&lt;/li&gt;
&lt;li&gt;A Linux host with systemd and Java installed (for the permanent agents).&lt;/li&gt;
&lt;li&gt;A test pipeline you can run safely. Do not validate hardening against your production release job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also decide up front which jobs are trusted. Pull-request builds from forks and release builds should never share an agent label. Hardening one agent type well is of little use if a single agent type is shared by everything.&lt;/p&gt;

&lt;h2&gt;Step 1: Keep builds off the controller and shrink the connection surface&lt;/h2&gt;



&lt;p&gt;The built-in node runs with the controller's filesystem access, including secrets and job configuration. Set its executors to zero so no build can land there.&lt;/p&gt;

&lt;p&gt;Then disable the fixed inbound TCP agent port and use WebSocket connections, which travel over your existing HTTPS endpoint.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# jenkins.yaml (Configuration as Code)
jenkins:
  numExecutors: 0        # built-in node runs no builds
  slaveAgentPort: -1     # disables the separate inbound TCP agent port
  mode: EXCLUSIVE        # only jobs with a matching label run on the built-in node
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Inbound agents then connect with the &lt;code&gt;-webSocket&lt;/code&gt; option. The &lt;code&gt;@&lt;/code&gt; prefix on &lt;code&gt;-secret&lt;/code&gt; reads the secret from a file. That keeps it off the command line, where it would show up in the process list. Confirm the exact flags for your agent version with &lt;code&gt;java -jar agent.jar -help&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Run as the unprivileged agent user, not root
java -jar agent.jar \
  -url https://jenkins.example.com/ \
  -name build-linux-01 \
  -secret @/etc/jenkins-agent/secret \
  -workDir /var/lib/jenkins-agent \
  -webSocket
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; turning off the TCP port while agents are still configured to use it. Existing inbound agents will fail to reconnect. Migrate them to WebSocket first, or schedule the change in a maintenance window. This includes Kubernetes plugin pod agents: enable the WebSocket option on the Kubernetes cloud configuration before you disable the port.&lt;/p&gt;

&lt;h2&gt;Step 2: Make agents ephemeral and unprivileged&lt;/h2&gt;

&lt;p&gt;A fresh pod per build removes the biggest persistence risk: leftover workspaces, caches, and tampered tools. The pod spec should:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run as a non-root user&lt;/li&gt;
&lt;li&gt;drop Linux capabilities&lt;/li&gt;
&lt;li&gt;avoid mounting the host's container socket&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Kubernetes documentation on &lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/security-context/" rel="noopener noreferrer"&gt;security contexts&lt;/a&gt; covers each field.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: Pod
spec:
  automountServiceAccountToken: false   # builds rarely need Kubernetes API access
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: jnlp                         # default agent container name in the Kubernetes plugin
      image: jenkins/inbound-agent:latest-jdk21   # pin a version or digest in real use
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: work
          mountPath: /home/jenkins/agent
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: work
      emptyDir: {}
    - name: tmp
      emptyDir: {}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; &lt;code&gt;readOnlyRootFilesystem&lt;/code&gt; breaks tools that write to the home directory or a cache path. Add an &lt;code&gt;emptyDir&lt;/code&gt; for each path that needs writes rather than relaxing the setting.&lt;/p&gt;

&lt;p&gt;If builds need to produce container images, use a rootless builder instead of a privileged Docker-in-Docker sidecar. BuildKit in rootless mode and Buildah both work. Avoid Kaniko for new setups, because its original upstream repository has been archived.&lt;/p&gt;

&lt;p&gt;A pipeline can define its own pod template. A hardened default therefore does not stop a malicious &lt;code&gt;Jenkinsfile&lt;/code&gt; from requesting a privileged pod. Enforce the limits at the cluster level by labeling the agent namespace with &lt;code&gt;pod-security.kubernetes.io/enforce: restricted&lt;/code&gt;. Pod Security Admission then rejects pods that violate the Restricted standard. The spec above complies with it.&lt;/p&gt;

&lt;p&gt;Pair the pod spec with a Kubernetes NetworkPolicy. Limit egress to the controller, DNS, your artifact registry, and the package mirrors you actually use. NetworkPolicy is only enforced if your CNI plugin supports it. Default-open egress gives a compromised build an easy path to exfiltrate data.&lt;/p&gt;

&lt;h2&gt;Step 3: Confine permanent agents with systemd&lt;/h2&gt;

&lt;p&gt;Some workloads, such as hardware tests or macOS and Windows builds, cannot be ephemeral pods. For Linux agents of this kind, run the agent as a dedicated user under a systemd unit that restricts what the process can touch. The directives below are documented in &lt;code&gt;systemd.exec(5)&lt;/code&gt;. Verify that your distribution's systemd version supports each one. Note that systemd only treats lines beginning with &lt;code&gt;#&lt;/code&gt; as comments, so comments cannot follow a value.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# /etc/systemd/system/jenkins-agent.service
[Unit]
Description=Jenkins inbound agent
Wants=network-online.target
After=network-online.target

[Service]
User=jenkins-agent
Group=jenkins-agent
ExecStart=/usr/bin/java -jar /opt/jenkins-agent/agent.jar \
  -url https://jenkins.example.com/ -name build-linux-01 \
  -secret @/etc/jenkins-agent/secret -workDir /var/lib/jenkins-agent -webSocket
# Block privilege escalation via setuid binaries
NoNewPrivileges=true
# Filesystem read-only except ReadWritePaths
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/jenkins-agent
# Empty set: no capabilities
CapabilityBoundingSet=
Restart=on-failure

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Make the secret file readable only by the agent user (&lt;code&gt;chmod 600&lt;/code&gt;, owned by &lt;code&gt;jenkins-agent&lt;/code&gt;). Also clean the workspace between builds, because a permanent agent keeps whatever the last job left behind. Use the &lt;code&gt;cleanWs()&lt;/code&gt; step from the Workspace Cleanup plugin, or a scheduled wipe.&lt;/p&gt;

&lt;h2&gt;Step 4: Scope credentials and label trust levels&lt;/h2&gt;

&lt;p&gt;Even a hardened agent is only as safe as the secrets it receives. Store credentials in the Jenkins credentials store. Scope them to the folder that needs them rather than globally, and bind them only around the steps that use them. Use separate labels so untrusted builds never share an agent with deploy jobs.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;pipeline {
  agent { label 'untrusted-ephemeral' }   // PR builds: no deploy credentials exist here
  stages {
    stage('Test') {
      steps { sh './run-tests.sh' }
    }
    stage('Publish') {
      when {
        beforeAgent true                  // evaluate before allocating the trusted agent
        branch 'main'
      }
      agent { label 'trusted-release' }   // separate pool for release work
      steps {
        withCredentials([string(credentialsId: 'registry-token', variable: 'TOKEN')]) {
          sh 'echo "$TOKEN" | docker login registry.example.com -u ci --password-stdin'
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Labels in a &lt;code&gt;Jenkinsfile&lt;/code&gt; are not a control on their own, because a pull request can edit them. Enforce the separation on the controller:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Configure your branch source so fork pull requests run the &lt;code&gt;Jenkinsfile&lt;/code&gt; from the target branch. The GitHub Branch Source plugin does this through its fork trust setting.&lt;/li&gt;
&lt;li&gt;Restrict which jobs may use the trusted pool, for example with the Job Restrictions plugin or a cloud only the release folder can use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; credential masking only hides exact values in the console log. It does not stop a malicious step from transforming a secret and sending it elsewhere. That is why the trusted pool must be unreachable from untrusted pull requests. Prefer short-lived tokens over static keys, such as OIDC federation to your cloud provider where supported.&lt;/p&gt;

&lt;h2&gt;Verify and test Jenkins agent hardening&lt;/h2&gt;

&lt;p&gt;Run checks as the build would, using a throwaway pipeline. Each test should fail in the way you expect. A pass on a forbidden action means the control is missing.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# 1. Identity and privileges inside an ephemeral agent
kubectl -n jenkins-agents exec POD_NAME -c jnlp -- id                       # expect uid 1000, not 0
kubectl -n jenkins-agents exec POD_NAME -c jnlp -- touch /home/jenkins/probe  # expect "Read-only file system"

# 2. Container socket and API access
kubectl -n jenkins-agents exec POD_NAME -c jnlp -- ls /var/run/docker.sock   # expect not found
kubectl -n jenkins-agents exec POD_NAME -c jnlp -- ls /var/run/secrets/kubernetes.io/serviceaccount   # expect not found

# 3. Controller TCP agent port closed (run from a network peer)
nc -zv jenkins.example.com 50000    # use your previous port; expect failure

# 4. Permanent agent sandbox rating
systemd-analyze security jenkins-agent.service
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then complete these checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the Jenkins UI, confirm the built-in node shows zero executors and that a job with no matching label does not start on it.&lt;/li&gt;
&lt;li&gt;Run a pull-request build that attempts to read a deploy credential ID, and confirm it cannot resolve.&lt;/li&gt;
&lt;li&gt;Try to apply a privileged pod in the agent namespace, and confirm Pod Security Admission rejects it.&lt;/li&gt;
&lt;li&gt;Check that outbound requests to a host outside your NetworkPolicy allowlist time out.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Jenkins agent hardening is mostly about removing four assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;that the controller can build&lt;/li&gt;
&lt;li&gt;that agents live forever&lt;/li&gt;
&lt;li&gt;that containers may be root&lt;/li&gt;
&lt;li&gt;that every job deserves every secret&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with executors set to zero and ephemeral unprivileged pods, which are a practical first step for most setups. Then add systemd confinement and credential scoping for the agents that must persist. Keep the configuration in JCasC and Git so changes are reviewed. Revisit the &lt;a href="https://www.jenkins.io/doc/book/security/" rel="noopener noreferrer"&gt;Jenkins security documentation&lt;/a&gt; when you upgrade, since defaults and plugin behavior change between releases. For more infrastructure guides, browse &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/28/cloudflare-origin-hardening-authenticated-pulls-vs-ip-allowlist/" rel="noopener noreferrer"&gt;Cloudflare Origin Hardening: Authenticated Pulls vs IP Allowlist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/23/jenkins-shared-library-structure-for-reusable-pipelines/" rel="noopener noreferrer"&gt;Jenkins Shared Library Structure for Reusable Pipelines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/21/wordfence-scan-settings-plugin-scanning-vs-server-hardening/" rel="noopener noreferrer"&gt;Wordfence Scan Settings: Plugin Scanning vs Server Hardening&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kubernetes NetworkPolicy Baseline: 3 Mistakes That Break Isolation</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sun, 01 Nov 2026 08:01:53 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/kubernetes-networkpolicy-baseline-3-mistakes-that-break-isolation-1dkp</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/kubernetes-networkpolicy-baseline-3-mistakes-that-break-isolation-1dkp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/11/01/kubernetes-networkpolicy-baseline-3-mistakes-that-break-isolation" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A team applies a default-deny policy to a staging namespace on Friday afternoon, and within seconds every pod starts logging "temporary failure in name resolution". Or the opposite happens: the policy applies cleanly, nothing breaks, and nothing is actually blocked either. A NetworkPolicy baseline sounds like a simple YAML exercise, but the documented behavior has enough sharp edges that the same few mistakes keep appearing. This retrospective walks through those mistakes as patterns, not as a report of any specific outage.&lt;/p&gt;

&lt;h2&gt;Context: what a NetworkPolicy baseline is supposed to do&lt;/h2&gt;



&lt;p&gt;By default, Kubernetes pods accept traffic from any other pod in the cluster and can reach any destination. Namespaces are an organizational boundary, not a network one. A NetworkPolicy baseline changes that default: each namespace starts from "deny everything", and teams add narrow allow rules on top.&lt;/p&gt;

&lt;p&gt;Three properties of the &lt;code&gt;networking.k8s.io/v1&lt;/code&gt; API shape everything that follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Policies are allow-only and additive. There is no deny rule; the union of all policies selecting a pod defines what is permitted.&lt;/li&gt;
&lt;li&gt;A pod is only isolated for a direction (ingress or egress) once at least one policy selects it for that direction.&lt;/li&gt;
&lt;li&gt;The API object is only a declaration. Enforcement is the job of the CNI plugin or network provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal baseline usually has two parts: a default-deny policy for both directions, and an explicit allowance for cluster DNS. The example below shows the shape. Verify the DNS labels against your own cluster, since they depend on the distribution.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Baseline for one namespace: deny all ingress and egress for every pod
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: team-a
spec:
  podSelector: {}          # empty selector = every pod in the namespace
  policyTypes:
    - Ingress
    - Egress
---
# Allow DNS lookups to CoreDNS in kube-system
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: team-a
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system   # set automatically by the control plane (v1.21+)
          podSelector:
            matchLabels:
              k8s-app: kube-dns                          # CoreDNS label in most distributions
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53                                       # needed for large responses and truncation fallback
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Common failure 1: default-deny without a DNS and platform exception&lt;/h2&gt;

&lt;p&gt;The most typical failure is applying the deny policy first and discovering afterward that egress includes DNS. With &lt;code&gt;policyTypes: [Egress]&lt;/code&gt; and no egress rules, pods cannot resolve service names. The symptom looks like an application bug: connection errors, readiness probes that depend on external calls failing, startup crashes on config fetches.&lt;/p&gt;

&lt;p&gt;The fix is the DNS allowance shown above, but there are variations that catch people out. &lt;strong&gt;Watch out for NodeLocal DNSCache&lt;/strong&gt;: when it is deployed, pods may send queries to a node-local link-local address rather than the CoreDNS service IP. The node-local-dns pods typically run with &lt;code&gt;hostNetwork&lt;/code&gt;, so a namespace-and-pod selector may not match them, and an &lt;code&gt;ipBlock&lt;/code&gt; rule for the link-local address may be needed. Whether that works also depends on the CNI. Check your DNS configuration (&lt;code&gt;/etc/resolv.conf&lt;/code&gt; inside a pod) and consult your CNI documentation before assuming the CoreDNS rule is sufficient.&lt;/p&gt;

&lt;p&gt;Other platform dependencies tend to surface the same way. Operators and controllers need egress to the Kubernetes API server. Metrics scrapers need ingress from a monitoring namespace. Service meshes need to reach their control planes. API server traffic is a common trap because the control plane usually sits outside the pod network. Pod and namespace selectors do not match it, so it typically needs an &lt;code&gt;ipBlock&lt;/code&gt; rule. Whether that rule should target the &lt;code&gt;kubernetes&lt;/code&gt; Service IP or the actual endpoint IPs depends on how your CNI orders address translation and policy evaluation. A deny baseline needs a short, reviewed list of these platform exceptions, ideally delivered by the same mechanism that creates the namespace, so they never lag behind.&lt;/p&gt;

&lt;h2&gt;Common failure 2: policies that exist but are never enforced&lt;/h2&gt;

&lt;p&gt;The Kubernetes API server accepts a NetworkPolicy object regardless of whether anything enforces it. The documentation is explicit that creating the resource without a supporting network plugin has no effect. A cluster using a CNI without policy support, or a managed cluster where the policy feature has not been enabled, will happily store the baseline while traffic flows freely.&lt;/p&gt;

&lt;p&gt;This is the dangerous failure because it produces a green dashboard and a false sense of isolation. Reviewing the YAML alone cannot detect it; a traffic test is what confirms enforcement. &lt;strong&gt;Watch out for managed platforms&lt;/strong&gt; where enforcement is an opt-in add-on or a separate component, and where the behavior may differ by version. Check your provider's documentation rather than assuming.&lt;/p&gt;

&lt;p&gt;A simple connectivity probe turns the baseline into something testable. For example, run a throwaway pod in the isolated namespace and try to reach a service in another namespace. Because the baseline denies egress from &lt;code&gt;team-a&lt;/code&gt;, the expected result is a timeout, not a response. Verify with a command like the one below, adjusting the target to your environment.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Expect a timeout (exit code non-zero) if the egress baseline is enforced
kubectl -n team-a run np-test --rm -it --restart=Never \
  --image=busybox:stable -- \
  wget -qO- -T 3 http://web.team-b.svc.cluster.local   # -T 3 = 3 second timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Note that a DNS failure also exits non-zero. Busybox reports it as a "bad address" error rather than a timeout, so read the output, not just the exit code. Test against a pod IP as well, so you know which layer is blocking. This probe exercises &lt;code&gt;team-a&lt;/code&gt; egress. To verify ingress isolation for a namespace, run the probe from a namespace that does not deny egress and target a pod in the protected namespace.&lt;/p&gt;

&lt;h2&gt;Common failure 3: selector semantics that quietly widen access&lt;/h2&gt;

&lt;p&gt;The third pattern is subtle: YAML indentation changes the meaning of a rule. Inside a &lt;code&gt;from&lt;/code&gt; or &lt;code&gt;to&lt;/code&gt; list, a &lt;code&gt;namespaceSelector&lt;/code&gt; and &lt;code&gt;podSelector&lt;/code&gt; in the &lt;em&gt;same&lt;/em&gt; list item are combined with AND. Placed as &lt;em&gt;separate&lt;/em&gt; items, they are combined with OR. Take a rule intended as "pods labeled &lt;code&gt;app.kubernetes.io/name=prometheus&lt;/code&gt; in the monitoring namespace". Split into two items, it becomes "every pod in the monitoring namespace, plus every pod with that label in the policy's own namespace".&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Intended: only Prometheus pods from the monitoring namespace
ingress:
  - from:
      - namespaceSelector:          # one list item, two selectors = AND
          matchLabels:
            kubernetes.io/metadata.name: monitoring
        podSelector:
          matchLabels:
            app.kubernetes.io/name: prometheus
# Risky variant: a second dash before podSelector makes it OR
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Related traps include relying on custom namespace labels that anyone with namespace edit rights can change, and using &lt;code&gt;ipBlock&lt;/code&gt; for in-cluster traffic, where pod IPs may be rewritten or ephemeral. &lt;strong&gt;Watch out for hostNetwork pods and node-originated traffic&lt;/strong&gt; too. The documentation notes that behavior for hostNetwork pods is implementation-dependent, and that a pod cannot block access from its own node. That is why kubelet health probes generally keep working, but verify rather than assume. For namespace matching, prefer the &lt;code&gt;kubernetes.io/metadata.name&lt;/code&gt; label. The control plane sets it to the namespace name, and it cannot be changed to a different value.&lt;/p&gt;

&lt;h2&gt;Safer operating pattern&lt;/h2&gt;

&lt;p&gt;The pattern that avoids all three failures is boring and incremental. Prove enforcement first, ship the deny and DNS policies together, then add allow rules per application with review. Treat the policies as code, applied by the same pipeline that provisions namespaces, so no namespace exists in an unprotected state.&lt;/p&gt;

&lt;p&gt;A practical checklist, in order:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;1. Confirm the CNI enforces NetworkPolicy (read provider docs, then run a deny test)
2. Inventory platform dependencies: DNS, API server, metrics, mesh, ingress controller
3. Apply default-deny + DNS allow together, never deny alone
4. Add per-app allow policies using kubernetes.io/metadata.name and stable pod labels
5. Review every from/to block for AND vs OR (selectors in one item vs separate items)
6. Run automated allow and deny connectivity tests in CI or post-deploy
7. Use your CNI's flow logs or policy-verdict logs to find denied traffic before tightening
8. Re-test after CNI, Kubernetes, or node image upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For clusters with many namespaces, also look at the cluster-scoped AdminNetworkPolicy and BaselineAdminNetworkPolicy APIs from SIG Network's Network Policy API subproject. They aim to let platform teams set a baseline that namespace owners cannot override. However, they are distributed as CRDs rather than built into Kubernetes, are still pre-GA, and work only with CNIs that implement them. Check the project's current status and your CNI's support before depending on them.&lt;/p&gt;

&lt;p&gt;Finally, remember what a baseline does not cover: it is layer 3/4 filtering, not authentication or encryption. It pairs well with RBAC, Pod Security Admission, and mutual TLS, not instead of them. The authoritative reference for semantics and current behavior is the &lt;a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/" rel="noopener noreferrer"&gt;Kubernetes NetworkPolicy documentation&lt;/a&gt;. More practical Kubernetes and security notes are available at &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/11/kubernetes-ingress-manifest-or-gateway-api-which-to-ship/" rel="noopener noreferrer"&gt;Kubernetes Ingress Manifest or Gateway API: Which to Ship&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/16/kubernetes-hpa-and-vpa-rightsizing-fixing-autoscaling-thrash/" rel="noopener noreferrer"&gt;Kubernetes HPA and VPA Rightsizing: Fixing Autoscaling Thrash&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/15/fixing-readiness-and-liveness-probes-in-kubernetes-pods/" rel="noopener noreferrer"&gt;Fixing readiness and liveness probes in Kubernetes pods&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
    <item>
      <title>AWS Cost Anomaly Alerts: Route to Owners with Tag Context</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sat, 31 Oct 2026 08:02:19 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/aws-cost-anomaly-alerts-route-to-owners-with-tag-context-2hdo</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/aws-cost-anomaly-alerts-route-to-owners-with-tag-context-2hdo</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/31/aws-cost-anomaly-alerts-route-to-owners-with-tag-context" 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;A shared finance mailbox gets an email titled "Cost anomaly detected," and nobody knows whose problem it is. This is the usual failure with default AWS cost anomaly alerts: detection works, but the message goes to a person who cannot act on it. By the time someone finds the owning team, the spend may have run for another day.&lt;/p&gt;

&lt;p&gt;The goal here is narrower than "more alerts." Each anomaly should reach the team that owns the spend, with enough context to start investigating immediately. That context includes the service, account, usage type, dollar impact, and a link to the details. AWS Cost Anomaly Detection already does the statistical work. What you build is the routing layer around it.&lt;/p&gt;

&lt;p&gt;The design has four parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monitors scoped by service and by a cost allocation tag.&lt;/li&gt;
&lt;li&gt;An SNS topic that Cost Anomaly Detection can publish to.&lt;/li&gt;
&lt;li&gt;A subscription with a sensible impact threshold.&lt;/li&gt;
&lt;li&gt;A small Lambda that adds ownership context and forwards the alert to the right channel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The documented SNS payload carries the following fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the anomaly ID and the monitor ARN&lt;/li&gt;
&lt;li&gt;impact figures&lt;/li&gt;
&lt;li&gt;root causes: service, region, linked account, and usage type&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The payload does not carry the tags of individual resources. Ownership therefore has to come from which monitor fired or from your own mapping. Verify the current payload shape in the &lt;a href="https://docs.aws.amazon.com/cost-management/latest/userguide/manage-ad.html" rel="noopener noreferrer"&gt;Cost Anomaly Detection documentation&lt;/a&gt; before relying on specific fields.&lt;/p&gt;

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

&lt;p&gt;Cost Anomaly Detection depends on Cost Explorer, so Cost Explorer must be enabled in the account. If you use AWS Organizations, create monitors in the management account to cover linked accounts. Cost Explorer and anomaly APIs are served from &lt;code&gt;us-east-1&lt;/code&gt;, so set that region for the CLI calls.&lt;/p&gt;

&lt;p&gt;Your tagging has to be ready too. Pick one tag key that identifies ownership, for example &lt;code&gt;team&lt;/code&gt;, and activate it as a cost allocation tag in the Billing console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for the activation delay:&lt;/strong&gt; activation can take up to 24 hours to appear in cost data. By default, the tag only applies going forward. You can request a backfill of historical data for activated tags, so check the current backfill limits in the Billing documentation. A tag monitor created before there is tagged cost history will have little to evaluate.&lt;/p&gt;

&lt;p&gt;You also need permissions to create the pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ce:CreateAnomalyMonitor&lt;/code&gt; and &lt;code&gt;ce:CreateAnomalySubscription&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sns:CreateTopic&lt;/code&gt; and &lt;code&gt;sns:SetTopicAttributes&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Lambda and IAM permissions for the router function&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have a chat webhook URL ready and a short list mapping each owner to a channel. The example below posts a &lt;code&gt;{"text": ...}&lt;/code&gt; body, which matches Slack incoming webhooks. Other tools expect their own payload format. For Microsoft Teams, legacy Office 365 connectors have been retired in favor of Workflows.&lt;/p&gt;

&lt;p&gt;If you prefer a managed chat integration, Amazon Q Developer in chat applications (formerly AWS Chatbot) can subscribe to an SNS topic directly. Check current docs for how it renders Cost Anomaly messages. A custom Lambda is what lets you add your own ownership fields.&lt;/p&gt;

&lt;h2&gt;Step 1: Create monitors that match how you assign ownership&lt;/h2&gt;



&lt;p&gt;A monitor defines what is watched. Start with a monitor on the AWS service dimension, which catches surprises nobody tagged. Accounts are typically limited to one AWS services monitor, and many already have a default one. Run &lt;code&gt;aws ce get-anomaly-monitors&lt;/code&gt; first and reuse an existing monitor if present.&lt;/p&gt;

&lt;p&gt;Then add one cost allocation tag monitor per team value. A separate monitor per team means the alert's &lt;code&gt;monitorArn&lt;/code&gt; tells you exactly whose tag the spend fell under.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Service-level monitor: catches spikes in any AWS service (skip if one exists)
aws ce create-anomaly-monitor --region us-east-1 --anomaly-monitor '{
  "MonitorName": "aws-services",
  "MonitorType": "DIMENSIONAL",
  "MonitorDimension": "SERVICE"
}'

# One tag monitor per team, so monitorArn in the alert identifies the owner
for team in payments data platform; do
  aws ce create-anomaly-monitor --region us-east-1 \
    --query MonitorArn --output text --anomaly-monitor "{
    \"MonitorName\": \"team-${team}\",
    \"MonitorType\": \"CUSTOM\",
    \"MonitorSpecification\": {
      \"Tags\": {\"Key\": \"team\", \"Values\": [\"${team}\"], \"MatchOptions\": [\"EQUALS\"]}
    }
  }"
done
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Each call returns a &lt;code&gt;MonitorArn&lt;/code&gt;. Save them all, because they become keys in the owner map. Monitor counts are subject to service quotas, so verify the current limits if you have many teams. The service monitor will also fire for untagged spend, which makes it a useful catch-all owned by your platform or FinOps group.&lt;/p&gt;

&lt;h2&gt;Step 2: Create an SNS topic Cost Anomaly Detection can publish to&lt;/h2&gt;

&lt;p&gt;Email subscribers can receive daily or weekly summaries, but &lt;strong&gt;SNS subscribers only receive individual (immediate) alerts&lt;/strong&gt;. That is what you want for routing.&lt;/p&gt;

&lt;p&gt;The topic needs a resource policy that allows the &lt;code&gt;costalerts.amazonaws.com&lt;/code&gt; service principal to publish. Without it, the subscription is created but alerts never arrive. If the topic uses SSE-KMS encryption, the key policy must also allow that principal to use the key.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;aws sns create-topic --name cost-anomaly-alerts --region us-east-1

# policy.json: allow the Cost Anomaly Detection service to publish
cat &amp;gt; policy.json &amp;lt;&amp;lt;'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCostAnomalyPublish",
    "Effect": "Allow",
    "Principal": {"Service": "costalerts.amazonaws.com"},
    "Action": "SNS:Publish",
    "Resource": "arn:aws:sns:us-east-1:111122223333:cost-anomaly-alerts",
    "Condition": {"StringEquals": {"aws:SourceAccount": "111122223333"}}
  }]
}
EOF

aws sns set-topic-attributes --region us-east-1 \
  --topic-arn arn:aws:sns:us-east-1:111122223333:cost-anomaly-alerts \
  --attribute-name Policy --attribute-value file://policy.json
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Replace the account ID and ARN with your own. The &lt;code&gt;aws:SourceAccount&lt;/code&gt; condition is a good-practice guard against cross-account misuse. Confirm the recommended policy in the current docs.&lt;/p&gt;

&lt;h2&gt;Step 3: Attach a subscription with a meaningful threshold&lt;/h2&gt;

&lt;p&gt;A subscription links monitors to subscribers and sets the alert threshold. Set it too low and every small fluctuation pages someone, and people then mute the channel. The 75 USD absolute total impact below is only an illustrative starting value. Tune it once you see your own anomaly history.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;aws ce create-anomaly-subscription --region us-east-1 --anomaly-subscription '{
  "SubscriptionName": "route-to-lambda",
  "MonitorArnList": ["&amp;lt;SERVICE_MONITOR_ARN&amp;gt;", "&amp;lt;TEAM_PAYMENTS_MONITOR_ARN&amp;gt;",
                     "&amp;lt;TEAM_DATA_MONITOR_ARN&amp;gt;", "&amp;lt;TEAM_PLATFORM_MONITOR_ARN&amp;gt;"],
  "Subscribers": [{
    "Type": "SNS",
    "Address": "arn:aws:sns:us-east-1:111122223333:cost-anomaly-alerts"
  }],
  "Frequency": "IMMEDIATE",
  "ThresholdExpression": {
    "Dimensions": {
      "Key": "ANOMALY_TOTAL_IMPACT_ABSOLUTE",
      "MatchOptions": ["GREATER_THAN_OR_EQUAL"],
      "Values": ["75"]
    }
  }
}'
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Older examples use a plain &lt;code&gt;Threshold&lt;/code&gt; field, which is deprecated. &lt;code&gt;ThresholdExpression&lt;/code&gt; is the current form and also supports percentage-based impact. The two fields cannot be combined, so check the API reference for your CLI version.&lt;/p&gt;

&lt;h2&gt;Step 4: Enrich and route with a small Lambda&lt;/h2&gt;

&lt;p&gt;Subscribe a Lambda function to the SNS topic. It reads the anomaly, looks up an owner from a mapping you maintain, and posts a readable message.&lt;/p&gt;

&lt;p&gt;The mapping is passed as an environment variable. Its keys are either monitor ARNs (for the per-team tag monitors) or linked account IDs (for spend caught by the service monitor). In a larger setup, keep it in SSM Parameter Store or DynamoDB instead.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import json, os, urllib.request

# Keys: monitor ARNs or linked account IDs, e.g.
# {"arn:aws:ce::111122223333:anomalymonitor/EXAMPLE": {"team": "payments", "webhook": "https://..."},
#  "444455556666": {"team": "data", "webhook": "https://..."}}
OWNERS = json.loads(os.environ["OWNER_MAP"])
DEFAULT = {"team": "finops", "webhook": os.environ["DEFAULT_WEBHOOK"]}

def handler(event, context):
    for record in event["Records"]:
        a = json.loads(record["Sns"]["Message"])
        causes = a.get("rootCauses") or [{}]
        top = causes[0]
        owner = (OWNERS.get(a.get("monitorArn"))
                 or OWNERS.get(top.get("linkedAccount"))
                 or DEFAULT)  # fall back to FinOps
        text = (
            f"Cost anomaly for {owner['team']}\n"
            f"Impact: ${a['impact']['totalImpact']:.2f} "
            f"(expected ${a['impact']['totalExpectedSpend']:.2f})\n"
            f"Service: {top.get('service')} | Region: {top.get('region')} | "
            f"Usage type: {top.get('usageType')}\n"
            f"Details: {a.get('anomalyDetailsLink')}"
        )
        req = urllib.request.Request(
            owner["webhook"],
            data=json.dumps({"text": text}).encode(),
            headers={"Content-Type": "application/json"},
        )
        # Let errors raise: Lambda retries failed async invocations
        urllib.request.urlopen(req, timeout=5)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Allow SNS to invoke the function with &lt;code&gt;aws lambda add-permission&lt;/code&gt;, then subscribe the function to the topic. SNS invokes Lambda asynchronously, so a failed webhook call is retried by Lambda's async retry mechanism, not by SNS. Configure an on-failure destination or dead-letter queue so alerts that exhaust their retries are not lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; the payload's root causes may already include a linked account name. If you resolve extra account details or account tags through AWS Organizations, note that only the management account (or a delegated administrator) can call those APIs. A function in a member account will get access denied unless you provide a cross-account role.&lt;/p&gt;

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

&lt;p&gt;You cannot force a real anomaly on demand, so test in two layers.&lt;/p&gt;

&lt;p&gt;First, publish a synthetic message shaped like the documented payload to your topic. This confirms the Lambda parses it, finds an owner, and reaches the webhook. Trim the sample to the fields your code reads, and use a real monitor ARN from your map to exercise that lookup path.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;aws sns publish --region us-east-1 \
  --topic-arn arn:aws:sns:us-east-1:111122223333:cost-anomaly-alerts \
  --message '{"anomalyDetailsLink":"https://console.aws.amazon.com/cost-management/home#/anomaly-detection",
    "monitorArn":"&amp;lt;TEAM_PAYMENTS_MONITOR_ARN&amp;gt;",
    "impact":{"totalImpact":240.5,"totalExpectedSpend":60.0},
    "rootCauses":[{"service":"Amazon EC2","region":"eu-west-1",
      "linkedAccount":"111122223333","usageType":"BoxUsage:m5.4xlarge"}]}'

# Check the function logs for parse errors or webhook failures
aws logs tail /aws/lambda/cost-anomaly-router --since 10m --region us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Second, confirm the real path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;aws ce get-anomalies&lt;/code&gt; with a &lt;code&gt;--date-interval&lt;/code&gt; to see whether the monitors have produced history.&lt;/li&gt;
&lt;li&gt;Check the Cost Anomaly Detection console for subscription status.&lt;/li&gt;
&lt;li&gt;Watch for the first genuine alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the topic receives nothing while the console shows anomalies, recheck the SNS topic policy (and the KMS key policy, if the topic is encrypted). That is a frequent cause and the first thing to rule out. A related failure is a tag monitor that never fires. This happens when the tag was activated recently or when the values do not match exactly, including case.&lt;/p&gt;

&lt;p&gt;Also confirm the fallback works by testing an account ID and monitor ARN that are missing from the owner map. The alert should land in the FinOps channel, not disappear.&lt;/p&gt;

&lt;h2&gt;Where this leaves you&lt;/h2&gt;

&lt;p&gt;Routing is only as good as the ownership data behind it. Treat the owner map and the tag policy as living configuration, and update the map whenever you add a team monitor or a new account. Revisit the threshold after a month of real alerts. Keep the catch-all channel staffed so unowned spend gets triaged instead of ignored.&lt;/p&gt;

&lt;p&gt;For more AWS operational patterns, browse the rest of &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;. Re-check payload fields and API limits against the official documentation, since both can change between releases.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/22/slo-burn-rate-alerts-explained-through-a-traffic-spike/" rel="noopener noreferrer"&gt;SLO burn-rate alerts explained through a traffic spike&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/25/postgresql-vacuum-monitoring-stop-table-bloat-before-it-hurts/" rel="noopener noreferrer"&gt;PostgreSQL VACUUM Monitoring: Stop Table Bloat Before It Hurts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/24/lambda-cold-start-in-python-diagnose-and-cut-init-time/" rel="noopener noreferrer"&gt;Lambda Cold Start in Python: Diagnose and Cut Init Time&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>WireGuard Key Rotation Checklist for Multi-Peer Setups</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Fri, 30 Oct 2026 08:01:44 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/wireguard-key-rotation-checklist-for-multi-peer-setups-4535</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/wireguard-key-rotation-checklist-for-multi-peer-setups-4535</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/30/wireguard-key-rotation-checklist-for-multi-peer-setups" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;WireGuard key rotation usually gets planned the day after a laptop disappears from a taxi, when nobody can say which of the 40 peers on the hub belongs to it. The tunnel itself is simple. The operational picture around it (who holds which key, which address belongs to whom, what breaks if a key changes) is the part that rarely gets written down.&lt;/p&gt;

&lt;p&gt;This checklist is for hub-and-spoke or partial-mesh deployments with enough peers that rotating by hand and from memory becomes risky. It assumes Linux hosts using &lt;code&gt;wg&lt;/code&gt; and &lt;code&gt;wg-quick&lt;/code&gt;, though the logic applies to other implementations.&lt;/p&gt;

&lt;h2&gt;Why this WireGuard key rotation checklist exists&lt;/h2&gt;



&lt;p&gt;WireGuard negotiates fresh session keys every couple of minutes, but the static keypair identifying each peer never changes on its own. There is no certificate expiry, no revocation list, and no built-in rotation. A peer's public key &lt;em&gt;is&lt;/em&gt; its identity, so rotation is an operational process you have to design yourself.&lt;/p&gt;

&lt;p&gt;Two properties make multi-peer rotation tricky. First, a given &lt;code&gt;AllowedIPs&lt;/code&gt; range maps to exactly one peer on an interface, so you cannot keep the old and new key of the same peer active with the same address. Second, the hub's key is configured on every spoke, so rotating it touches the whole fleet at once.&lt;/p&gt;

&lt;p&gt;A typical failure is a rotation that succeeds on the hub and is never finished on the spoke. The tunnel silently stops, and the only symptom is a handshake timestamp that never advances. The list below is meant to prevent that: plan, stage, verify, and keep a way back. Confirm details against the official &lt;a href="https://www.wireguard.com/quickstart/" rel="noopener noreferrer"&gt;WireGuard documentation&lt;/a&gt; and the &lt;a href="https://man7.org/linux/man-pages/man8/wg.8.html" rel="noopener noreferrer"&gt;wg(8) man page&lt;/a&gt; for your tool version.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build an inventory first.&lt;/strong&gt; For every peer, record public key, assigned &lt;code&gt;AllowedIPs&lt;/code&gt;, owner, creation date, and whether a preshared key is used. A spreadsheet or a Git-tracked file works. Without this, you cannot tell which keys are stale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a rotation trigger.&lt;/strong&gt; Decide on a schedule (for example, every 6 to 12 months) plus event triggers: device loss, staff departure, suspected disclosure. Write both down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate keys on the peer itself.&lt;/strong&gt; The private key should never travel. Only the public key (and, if used, a preshared key delivered over a separate secure channel) moves between machines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up the current config.&lt;/strong&gt; Copy the hub's config and the output of &lt;code&gt;wg show&lt;/code&gt; before touching anything. This is your rollback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a cutover pattern per peer.&lt;/strong&gt; Because one address maps to one key, either swap atomically on the hub (remove old, add new in one command) or give the new key a temporary address and move traffic over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate the spoke, then the hub entry.&lt;/strong&gt; Apply the new private key (and preshared key, if used) locally, then apply the swap on the hub immediately after. Keep the gap between the two steps to seconds. If you manage the spoke over the tunnel itself, have out-of-band access ready, because the tunnel drops until both sides match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the handshake.&lt;/strong&gt; Generate some traffic, since handshakes only happen when packets flow. Then check &lt;code&gt;wg show wg0 latest-handshakes&lt;/code&gt; for the peer and test real application traffic, not just ping to the tunnel address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist the change.&lt;/strong&gt; Runtime changes made with &lt;code&gt;wg set&lt;/code&gt; are lost on restart unless the config file is updated too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove the old public key everywhere.&lt;/strong&gt; Check the hub, any secondary hubs, and any spokes that peer directly with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update the inventory and archive the old private key securely, or destroy it.&lt;/strong&gt; Record the rotation date.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The next block shows steps 3, 5 and 6 for a single spoke. The first part runs on the peer, the second on the hub.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# On the peer: umask keeps new files unreadable by other users
umask 077
wg genkey | tee /etc/wireguard/wg0.key.new | wg pubkey &amp;gt; /etc/wireguard/wg0.pub.new
wg genpsk &amp;gt; /etc/wireguard/wg0.psk.new   # optional per-peer preshared key

# On the peer, at cutover: switch to the new private key and set the
# preshared key for the hub peer (omit the second line if no PSK is used)
wg set wg0 private-key /etc/wireguard/wg0.key.new \
  peer HUB_PUBLIC_KEY preshared-key /etc/wireguard/wg0.psk.new

# On the hub: one command removes the old peer and adds the new one,
# so the AllowedIPs move without a window where neither key is configured.
# peer-b.psk must contain the same value as the peer's wg0.psk.new,
# copied over a separate secure channel.
wg set wg0 \
  peer OLD_PUBLIC_KEY remove \
  peer NEW_PUBLIC_KEY preshared-key /etc/wireguard/peer-b.psk allowed-ips 10.8.0.12/32
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Treat the hub's own key as a separate project. One workable pattern is to bring up a second interface with the new key on a different &lt;code&gt;ListenPort&lt;/code&gt; and its own tunnel subnet. Overlapping &lt;code&gt;AllowedIPs&lt;/code&gt; on two interfaces produce conflicting routes. Then migrate spokes in waves, updating their &lt;code&gt;Endpoint&lt;/code&gt; port and addresses, and retire the old interface when the last handshake moves over.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Watch out for a stale &lt;code&gt;Endpoint&lt;/code&gt; or &lt;code&gt;PersistentKeepalive&lt;/code&gt; assumption.&lt;/strong&gt; A peer behind NAT that was reachable because of keepalives from the old config may go quiet if the new config omits them. The hub cannot initiate to it until the spoke sends a packet first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for &lt;code&gt;SaveConfig = true&lt;/code&gt;.&lt;/strong&gt; With it enabled, &lt;code&gt;wg-quick&lt;/code&gt; rewrites the config file from runtime state on shutdown. A hand-edited file can be overwritten by the old in-memory state. Pick one source of truth, and verify the behavior in the &lt;code&gt;wg-quick&lt;/code&gt; man page for your version.&lt;/p&gt;

&lt;p&gt;Other items that tend to slip through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Preshared keys.&lt;/strong&gt; They are symmetric and per peer pair. Rotating the keypair but leaving a years-old preshared key in place defeats part of the purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private key file permissions.&lt;/strong&gt; Config files containing &lt;code&gt;PrivateKey&lt;/code&gt; should be mode 600, owned by root. Backups and config-management copies often end up world-readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys in shell history, tickets, and chat.&lt;/strong&gt; Public keys are fine to share. A private key pasted into a ticket must be treated as compromised.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firewall rules and DNS entries tied to the old key or address.&lt;/strong&gt; If you used a temporary address during cutover, remove it afterward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring that keys off the public key.&lt;/strong&gt; Dashboards and alerts labeled by old key will show a "missing" peer and a new unnamed one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline peers.&lt;/strong&gt; A laptop that is off for a month will return with the old key. Decide in advance whether it gets re-enrolled or stays blocked.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Rotation by hand is manageable for a handful of peers and becomes error-prone as the peer count grows. The goal of automation is not to hide the process but to make the inventory, the swap, and the verification repeatable. Start with the pieces that cause the most mistakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staleness reporting.&lt;/strong&gt; A scheduled job can list peers with no recent handshake, which catches both dead devices and half-finished rotations. For example:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Report peers with no handshake in the last 10 minutes (timestamp 0 = never)
now=$(date +%s)
wg show wg0 latest-handshakes | while read -r key ts; do
  if [ "$ts" -eq 0 ] || [ $((now - ts)) -gt 600 ]; then
    echo "stale: $key"   # feed this into your alerting or ticketing
  fi
done
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Note that an idle but healthy peer without keepalives can also show an old handshake, so tune the threshold to your traffic pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Config generation.&lt;/strong&gt; Keep peer definitions as data (YAML or similar) in Git and render &lt;code&gt;wg0.conf&lt;/code&gt; from it with Ansible, Terraform, or a small script. Reviews of the rendered diff catch duplicate &lt;code&gt;AllowedIPs&lt;/code&gt; before they reach the hub. Apply changes with &lt;code&gt;wg syncconf wg0 &amp;lt;(wg-quick strip wg0)&lt;/code&gt; so existing sessions are not torn down; verify this against your installed version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secret handling.&lt;/strong&gt; Store private keys in a secrets manager or generate them on the host and never export them. A timer-driven job on each spoke can generate a new keypair, submit only the public key to an enrollment API, and wait for confirmation before switching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staged rollout.&lt;/strong&gt; Rotate a canary peer first, check the handshake, then proceed in batches with automatic halt on failed verification. For more infrastructure operations material, see &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Whatever you automate, keep the manual rollback path documented and tested. The backup from step 4 is only useful if someone has restored it before.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/27/nginx-rate-limiting-checklist-for-public-apis/" rel="noopener noreferrer"&gt;Nginx Rate Limiting Checklist for Public APIs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/03/pgvector-semantic-search-in-postgresql-a-python-checklist/" rel="noopener noreferrer"&gt;pgvector Semantic Search in PostgreSQL: A Python Checklist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/28/cloudflare-origin-hardening-authenticated-pulls-vs-ip-allowlist/" rel="noopener noreferrer"&gt;Cloudflare Origin Hardening: Authenticated Pulls vs IP Allowlist&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Ansible Rolling Deployment: 6 Tips for Zero-Downtime Releases</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:01:43 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/ansible-rolling-deployment-6-tips-for-zero-downtime-releases-2j9</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/ansible-rolling-deployment-6-tips-for-zero-downtime-releases-2j9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/29/ansible-rolling-deployment-6-tips-for-zero-downtime-releases" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A single &lt;code&gt;ansible-playbook site.yml&lt;/code&gt; that restarts every web node at once turns a routine release into a short outage. An Ansible rolling deployment avoids that by updating hosts in small batches. Each host is taken out of the load balancer first and put back only after it proves healthy. The tips below are independent, so use whichever fits your setup.&lt;/p&gt;

&lt;p&gt;The examples assume an inventory group &lt;code&gt;web&lt;/code&gt;, an HAProxy host &lt;code&gt;lb1&lt;/code&gt;, and a &lt;code&gt;/healthz&lt;/code&gt; endpoint on each app node. Swap in your own load balancer module and health check as needed.&lt;/p&gt;

&lt;h2&gt;1. Start with one host, then widen the batches&lt;/h2&gt;



&lt;p&gt;&lt;strong&gt;Use a list for &lt;code&gt;serial&lt;/code&gt; so the first batch acts as a canary.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;serial&lt;/code&gt; accepts integers, percentages, or a list of both. A list such as &lt;code&gt;[1, "25%", "100%"]&lt;/code&gt; updates one host, then a quarter of the fleet, then everything that remains. If the canary breaks, the blast radius is one node. Percentages are calculated against the hosts in the play, so they scale as the fleet grows.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Rolling application release
  hosts: web
  serial: [1, "25%", "100%"]      # canary, then a quarter, then the rest
  max_fail_percentage: 0          # any failure in a batch stops the rollout
  tasks:
    - name: Deploy release
      ansible.builtin.include_role:
        name: app_release
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; &lt;code&gt;max_fail_percentage&lt;/code&gt; is evaluated per batch, not across the whole play. With a one-host canary and a 30% threshold, a single failure is already 100% of that batch. Check how the threshold behaves at your smallest batch size.&lt;/p&gt;

&lt;h2&gt;2. Drain each node before touching it&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Remove the host from the load balancer in &lt;code&gt;pre_tasks&lt;/code&gt; and delegate the call to the balancer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Restarting a service that is still receiving traffic drops in-flight requests, even when the batches are small. Disable the backend server first and wait for connections to finish. The &lt;code&gt;delegate_to&lt;/code&gt; keyword runs the task on the balancer while still using the current app host's variables.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;  pre_tasks:
    - name: Drain node from HAProxy
      community.general.haproxy:
        backend: app_backend
        host: "{{ inventory_hostname }}"   # must match the server name in haproxy.cfg
        state: disabled
        drain: true                        # let existing sessions finish
        wait: true
        wait_retries: 30
        wait_interval: 2
      delegate_to: lb1
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The module talks to HAProxy through its runtime stats socket. That socket must be configured with &lt;code&gt;level admin&lt;/code&gt; in &lt;code&gt;haproxy.cfg&lt;/code&gt;, and its path set with the &lt;code&gt;socket&lt;/code&gt; option if it differs from the default &lt;code&gt;/var/run/haproxy.sock&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Check the module options with &lt;code&gt;ansible-doc community.general.haproxy&lt;/code&gt;, since parameters can differ between collection versions. On AWS, the equivalent is deregistering the target from the target group and waiting for the deregistration delay to pass.&lt;/p&gt;

&lt;h2&gt;3. Gate re-entry on a real health check&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A service that is "started" is not a service that is ready.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;Type=simple&lt;/code&gt; or &lt;code&gt;Type=forking&lt;/code&gt; units, systemd reports the start as successful as soon as the process is launched. It does not wait for the application to be ready to serve. Only &lt;code&gt;Type=notify&lt;/code&gt; units that send an explicit readiness signal change that. Even then, the app may still be warming caches or waiting for its database pool.&lt;/p&gt;

&lt;p&gt;Poll the health endpoint with &lt;code&gt;until&lt;/code&gt;, and re-enable the node in &lt;code&gt;post_tasks&lt;/code&gt; only after it returns 200.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;  post_tasks:
    - name: Wait for application health
      ansible.builtin.uri:
        url: "http://{{ inventory_hostname }}:8080/healthz"
        status_code: 200
      register: health
      until: health.status == 200
      retries: 20
      delay: 3                 # up to ~60 seconds before the host is marked failed

    - name: Return node to rotation
      community.general.haproxy:
        backend: app_backend
        host: "{{ inventory_hostname }}"
        state: enabled
      delegate_to: lb1
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Make the health endpoint check real dependencies, not just return a static "ok". Otherwise this gate only proves the web server process is running.&lt;/p&gt;

&lt;h2&gt;4. Flush handlers before the health check&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Restart handlers run late by default, so force them to run inside the batch.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Handlers run at the end of the current section (&lt;code&gt;pre_tasks&lt;/code&gt;, &lt;code&gt;tasks&lt;/code&gt;, &lt;code&gt;post_tasks&lt;/code&gt;), not right after the task that notifies them. If your restart handler is notified in &lt;code&gt;tasks&lt;/code&gt;, it fires after &lt;code&gt;tasks&lt;/code&gt; finishes, which is before &lt;code&gt;post_tasks&lt;/code&gt;. That ordering is usually fine. It breaks as soon as someone moves the health check into &lt;code&gt;tasks&lt;/code&gt;. Make the order explicit instead of relying on it.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;    - name: Render app config
      ansible.builtin.template:
        src: app.conf.j2
        dest: /etc/app/app.conf
      notify: Restart app

    - name: Apply pending restarts now
      ansible.builtin.meta: flush_handlers   # restart happens here, not later
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; if a task fails before the handler runs, the notified handler is skipped for that host. Set &lt;code&gt;force_handlers: true&lt;/code&gt; (or pass &lt;code&gt;--force-handlers&lt;/code&gt;) to change that. A node with changed files but no restart is a typical source of "the playbook failed but the files changed" confusion.&lt;/p&gt;

&lt;h2&gt;5. Decide what a failed host looks like&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Know whether you want a broken node left drained or put back.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a host fails, Ansible drops it from the rest of the play. In an Ansible rolling deployment, that means it stays disabled in the load balancer, because the re-enable task never ran. That is usually the safe outcome, since a bad node does not get traffic. Still, make it deliberate and visible, because a silently drained node reduces capacity until someone notices.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;block&lt;/code&gt;/&lt;code&gt;rescue&lt;/code&gt; if you want a rollback step, such as reinstalling the previous artifact. Be aware that a host whose &lt;code&gt;rescue&lt;/code&gt; succeeds is not counted as failed. It continues into &lt;code&gt;post_tasks&lt;/code&gt;, does not count toward &lt;code&gt;max_fail_percentage&lt;/code&gt;, and will be returned to rotation if the health check passes. If you want a rolled-back node to stay drained, or the rollout to stop, end the &lt;code&gt;rescue&lt;/code&gt; with &lt;code&gt;ansible.builtin.fail&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;any_errors_fatal: true&lt;/code&gt; if one failure should abort the whole rollout. The current task finishes on the batch, then the play ends for all hosts, and no further batches run.&lt;/p&gt;

&lt;p&gt;Pair either choice with an alert or a final summary task. That way a reduced pool is reported, not discovered later.&lt;/p&gt;

&lt;p&gt;Also confirm that the remaining healthy nodes can carry the load. With &lt;code&gt;serial: "50%"&lt;/code&gt;, half the fleet is out of rotation during every batch. The percentage should reflect your headroom, not just your patience.&lt;/p&gt;

&lt;h2&gt;6. Keep migrations out of the serial play&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run schema changes once, in a separate play, and keep them backward compatible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The documented behavior of &lt;code&gt;run_once&lt;/code&gt; combined with &lt;code&gt;serial&lt;/code&gt; is that the task runs once &lt;em&gt;per batch&lt;/em&gt;, not once per play. A migration marked &lt;code&gt;run_once: true&lt;/code&gt; inside a serial play will execute again for every batch. Put it in its own play ahead of the rolling one.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Database migration (once)
  hosts: web[0]                 # a single host runs the migration
  tasks:
    - name: Run additive migrations
      ansible.builtin.command: /opt/app/bin/migrate --additive-only   # placeholder for your migration tool
      changed_when: true        # the command gives no change signal; verify with your tool

- name: Rolling application release
  hosts: web
  serial: [1, "25%", "100%"]
  # ... pre_tasks, tasks, post_tasks from the tips above
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;During a rollout, old and new code run side by side against the same database. Use the expand/contract approach: add columns and tables first, deploy the code, and drop old structures in a later release. Without that, even perfect batching causes errors on the nodes still running the previous version.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;serial&lt;/code&gt;, &lt;code&gt;max_fail_percentage&lt;/code&gt;, and &lt;code&gt;run_once&lt;/code&gt;, see the official &lt;a href="https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_strategies.html" rel="noopener noreferrer"&gt;Ansible documentation on playbook strategies and rolling batches&lt;/a&gt;. For &lt;code&gt;delegate_to&lt;/code&gt;, see the official &lt;a href="https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_delegation.html" rel="noopener noreferrer"&gt;Ansible documentation on controlling where tasks run&lt;/a&gt;. More automation and CI/CD notes are on &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/16/ansible-server-bootstrap-locks-you-out-of-new-hosts-fix/" rel="noopener noreferrer"&gt;Ansible server bootstrap locks you out of new hosts: fix&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/05/cisco-config-backups-with-ansible-ios-config-vs-ios-command/" rel="noopener noreferrer"&gt;Cisco Config Backups with Ansible: ios_config vs ios_command&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/27/nginx-rate-limiting-checklist-for-public-apis/" rel="noopener noreferrer"&gt;Nginx Rate Limiting Checklist for Public APIs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Cloudflare Origin Hardening: Authenticated Pulls vs IP Allowlist</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:02:12 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/cloudflare-origin-hardening-authenticated-pulls-vs-ip-allowlist-21mo</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/cloudflare-origin-hardening-authenticated-pulls-vs-ip-allowlist-21mo</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/28/cloudflare-origin-hardening-authenticated-pulls-vs-ip-allowlist" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A team turns on managed WAF rules and bot challenges, watches the attack traffic drop in the dashboard, and then finds the same junk still hitting the application logs. The cause is usually mundane: the origin server still answers on a public IP, so anyone who learns the address can skip the edge. Cloudflare origin hardening exists to close that gap, and there are two main ways to do it: restrict by network address, or require proof of identity from the connecting client.&lt;/p&gt;

&lt;p&gt;Both approaches pair with the same two supporting pieces: Full (strict) SSL between Cloudflare and the origin, and edge rules for bot checks. This article compares the approaches by class so the choice does not depend on a specific plan tier or product revision. Verify plan-specific features in the &lt;a href="https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/" rel="noopener noreferrer"&gt;Cloudflare SSL modes documentation&lt;/a&gt; before you commit.&lt;/p&gt;

&lt;h2&gt;When this choice matters&lt;/h2&gt;



&lt;p&gt;Edge protections only work on traffic that passes through the edge. A WAF custom rule, a managed challenge, or a rate limit does nothing against a request sent straight to the origin IP. Origin addresses leak in several ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Old DNS records.&lt;/li&gt;
&lt;li&gt;Certificate transparency logs that reveal hostnames, some of which resolve to unproxied IPs.&lt;/li&gt;
&lt;li&gt;Mail headers.&lt;/li&gt;
&lt;li&gt;Forgotten subdomains that are not proxied.&lt;/li&gt;
&lt;li&gt;Internet-wide scan data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The choice matters most in these situations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You rely on Cloudflare bot checks or rate limiting to protect login, checkout, or API endpoints.&lt;/li&gt;
&lt;li&gt;The origin has an expensive code path, such as search or report generation, that a direct flood could exhaust.&lt;/li&gt;
&lt;li&gt;You recently moved a site behind Cloudflare, so the old origin IP may already be known.&lt;/li&gt;
&lt;li&gt;Compliance or customer contracts require that traffic is inspected before it reaches the application.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It matters less for a static site served from object storage, where there is no origin to attack. It also matters less if the origin already sits in a private network with no public listener. Rotating the origin IP after the move is cheap and worth doing, but on its own it is obscurity, not a control.&lt;/p&gt;

&lt;h2&gt;Option A: network-layer lockdown with Full (strict) SSL&lt;/h2&gt;

&lt;p&gt;The first class of approach is to make the origin firewall accept port 443 only from Cloudflare's published address ranges. Pair it with Full (strict) mode, which makes Cloudflare validate the origin certificate. The certificate must be unexpired, match the hostname, and be issued by a public CA or by Cloudflare Origin CA. The ranges are listed on the &lt;a href="https://developers.cloudflare.com/fundamentals/concepts/cloudflare-ip-addresses/" rel="noopener noreferrer"&gt;Cloudflare IP addresses page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The following script rebuilds host firewall rules from the published lists. It uses ufw as an example; adapt it to nftables or a cloud security group. Note that it resets all existing ufw rules, so add back any other services the host needs before running it.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/usr/bin/env bash
set -euo pipefail

ADMIN_CIDR="192.0.2.0/24"   # placeholder: your management network for SSH

tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
# The published files may lack a trailing newline, so add one between them
{ curl -fsSL https://www.cloudflare.com/ips-v4; echo; \
  curl -fsSL https://www.cloudflare.com/ips-v6; echo; } &amp;gt; "$tmp"

# Refuse to continue if the list is empty or contains anything but CIDRs
grep -Eq '^[0-9a-fA-F:.]+/[0-9]+$' "$tmp" || { echo "empty IP list"; exit 1; }
if grep -Evq '^([0-9a-fA-F:.]+/[0-9]+)?$' "$tmp"; then
  echo "unexpected content in IP list"; exit 1
fi

# reset deletes ALL existing rules (including stale ranges) and disables ufw
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow from "$ADMIN_CIDR" to any port 22 proto tcp   # keep SSH access
while read -r cidr; do
  [ -z "$cidr" ] &amp;amp;&amp;amp; continue
  ufw allow from "$cidr" to any port 443 proto tcp   # HTTPS only from Cloudflare
done &amp;lt; "$tmp"
ufw --force enable   # requires IPV6=yes in /etc/default/ufw for v6 rules&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Works with any web server, load balancer, or cloud firewall, with no TLS changes beyond Full (strict).&lt;/li&gt;
&lt;li&gt;Drops scanner traffic before it reaches the TLS handshake, which keeps logs quiet.&lt;/li&gt;
&lt;li&gt;Easy to audit: the rule set is a list of CIDRs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It proves the packet came from Cloudflare, not from your zone. Any Cloudflare customer can proxy to your origin IP from those same ranges, bypassing your WAF rules.&lt;/li&gt;
&lt;li&gt;The ranges change occasionally, so the list needs scheduled refreshes. &lt;strong&gt;Watch out for a stale list after a range is added; it can cause intermittent 521 or 522 errors that look like an application fault.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Forgetting IPv6 leaves a second door open on dual-stack hosts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Option B: identity-based origin hardening with Authenticated Origin Pulls&lt;/h2&gt;

&lt;p&gt;The second class asks Cloudflare to present a client certificate when it connects, and the origin rejects any connection without one. This is mutual TLS, documented as &lt;a href="https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/" rel="noopener noreferrer"&gt;Authenticated Origin Pulls&lt;/a&gt;. Because the check is cryptographic, it does not depend on source addresses staying stable.&lt;/p&gt;

&lt;p&gt;Here is an nginx server block that enforces it. The CA file comes from the Cloudflare documentation, or from your own CA if you use a custom certificate. Enable Authenticated Origin Pulls in Cloudflare first. If the origin starts requiring a client certificate before Cloudflare sends one, every request fails.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/ssl/origin/app.pem;   # Origin CA or public CA cert
    ssl_certificate_key /etc/ssl/origin/app.key;

    # CA that signs the client cert Cloudflare presents
    ssl_client_certificate /etc/ssl/origin/origin-pull-ca.pem;
    ssl_verify_client on;   # nginx answers 400 if no valid client cert is sent

    location / {
        proxy_pass http://127.0.0.1:8080;
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Apply the same verification to every server block that listens on 443, including the default_server. A request with an unknown Host or SNI value lands on the default block. If that block skips verification, it bypasses the check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A direct request to the origin is rejected with HTTP 400 before it reaches the application, even if the attacker knows the IP.&lt;/li&gt;
&lt;li&gt;No list of ranges to maintain.&lt;/li&gt;
&lt;li&gt;With a zone-level or per-hostname custom certificate, other Cloudflare tenants cannot route through to you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Requires TLS configuration on every origin, and not every load balancer or managed platform supports client certificate verification. Check yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch out for the shared default certificate.&lt;/strong&gt; The documented default uses a certificate common to all Cloudflare customers, which gives much weaker isolation than a zone-specific one.&lt;/li&gt;
&lt;li&gt;Certificate expiry is a new failure mode. A lapsed client cert on the Cloudflare side or a lapsed CA file at the origin takes the site down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A related variant is Cloudflare Tunnel, where a connector on the origin makes outbound connections and no inbound port is open. It removes the public listener entirely, at the cost of running and monitoring the connector. See the &lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/" rel="noopener noreferrer"&gt;Tunnel documentation&lt;/a&gt; for current setup.&lt;/p&gt;

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

&lt;p&gt;Bot checks do not change the matrix, since both options protect the path that the edge rules depend on. Those rules live at the edge: Bot Fight Mode or its higher-tier equivalents, plus WAF custom rules that issue a managed challenge on sensitive paths. Which bot features you get depends on plan, so verify the current tiers in the Cloudflare docs.&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;Allowlist + strict&lt;/th&gt;
&lt;th&gt;Authenticated pulls + strict&lt;/th&gt;
&lt;th&gt;Tunnel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Legacy origin, no TLS control&lt;/td&gt;
&lt;td&gt;Fits&lt;/td&gt;
&lt;td&gt;Often impossible&lt;/td&gt;
&lt;td&gt;Fits if a connector can run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concern: other Cloudflare tenants&lt;/td&gt;
&lt;td&gt;Does not address&lt;/td&gt;
&lt;td&gt;Addresses (custom cert)&lt;/td&gt;
&lt;td&gt;Addresses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational burden&lt;/td&gt;
&lt;td&gt;Range refresh job&lt;/td&gt;
&lt;td&gt;Certificate lifecycle&lt;/td&gt;
&lt;td&gt;Connector uptime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public inbound port needed&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollout effort&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;td&gt;Higher; needs staged testing&lt;/td&gt;
&lt;td&gt;Moderate; new component to run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use this checklist before changing anything in production:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[ ] Origin cert valid, hostname matches, not expiring within 30 days
[ ] Test Full (strict) on a staging hostname first (a bad cert returns 526)
[ ] Firewall rules cover IPv4 AND IPv6, and SSH/admin access is preserved
[ ] Health checks and monitoring probes allowed or routed via Cloudflare
[ ] Authenticated Origin Pulls enabled in Cloudflare BEFORE origin enforces it
[ ] WAF custom rule (managed challenge) staged: Log action if your plan has it,
    otherwise scoped to a test hostname first
[ ] Direct-to-origin request from outside Cloudflare now fails
[ ] Alert on cert expiry and on 52x error rate&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Evidence-based recommendation for Cloudflare origin hardening&lt;/h2&gt;

&lt;p&gt;The documented behavior points to a layered default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full (strict) SSL always.&lt;/li&gt;
&lt;li&gt;A firewall restriction to Cloudflare ranges as the baseline.&lt;/li&gt;
&lt;li&gt;Authenticated Origin Pulls with a zone-specific certificate wherever the origin can verify client certificates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The allowlist is cheap and blocks opportunistic scanning from non-Cloudflare addresses. Mutual TLS closes the remaining hole that an allowlist cannot: another tenant on the same ranges.&lt;/p&gt;

&lt;p&gt;Choose the allowlist alone when the origin is a legacy system you cannot reconfigure, the threat is commodity scanning, and you accept the cross-tenant gap. Choose mutual TLS when you run your own reverse proxy such as nginx and the application is a likely target. Choose Tunnel when you can run a connector and want no inbound exposure at all.&lt;/p&gt;

&lt;p&gt;Whichever you pick, confirm it works. From a host outside Cloudflare, request the origin IP directly with the correct Host and SNI, for example &lt;code&gt;curl -v --resolve app.example.com:443:203.0.113.10 https://app.example.com/&lt;/code&gt;. With a firewall restriction, expect a timeout or refused connection. With Authenticated Origin Pulls in nginx, expect an HTTP 400 complaining about a missing client certificate.&lt;/p&gt;

&lt;p&gt;Stage the bot and WAF rules before enforcing them, because challenges can break API clients and webhooks that cannot solve them. Use the Log action where your plan offers it, or scope rules to a test hostname first. For more infrastructure comparisons, browse &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;. Exact UI labels and plan limits change, so treat the Cloudflare documentation as the source of truth.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/21/wordfence-scan-settings-plugin-scanning-vs-server-hardening/" rel="noopener noreferrer"&gt;Wordfence Scan Settings: Plugin Scanning vs Server Hardening&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/06/cloudflare-tunnel-shows-502-or-inactive-troubleshooting-fixes/" rel="noopener noreferrer"&gt;Cloudflare Tunnel Shows 502 or Inactive? Troubleshooting Fixes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/24/when-cloudflare-rate-limit-locks-out-your-own-players/" rel="noopener noreferrer"&gt;When Cloudflare Rate Limit Locks Out Your Own Players&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Nginx Rate Limiting Checklist for Public APIs</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:02:21 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/nginx-rate-limiting-checklist-for-public-apis-348d</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/nginx-rate-limiting-checklist-for-public-apis-348d</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/27/nginx-rate-limiting-checklist-for-public-apis" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A public API goes live behind Nginx. A week later, one client starts retrying a failing call in a tight loop while a scraper walks every ID. Nginx rate limiting was added "later" as a single &lt;code&gt;limit_req&lt;/code&gt; line, and the upstream still falls over because the line keys on the wrong address or never applies to the location that matters. This checklist covers the settings that decide whether limiting protects the API or only looks like it does.&lt;/p&gt;

&lt;h2&gt;Why this checklist matters for Nginx rate limiting&lt;/h2&gt;



&lt;p&gt;Rate limiting in Nginx is a few directives, so it is easy to believe it is finished once the config reloads. The failure modes are quiet. A zone keyed on the wrong variable throttles a whole CDN instead of one client, and an empty key disables limiting for that request. A limit set at the server level can vanish when a location adds its own &lt;code&gt;limit_req&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nginx implements limiting with a leaky-bucket model. &lt;code&gt;limit_req&lt;/code&gt; controls request rate, and &lt;code&gt;limit_conn&lt;/code&gt; controls concurrent connections. Both are cheap and run before the request reaches your application. Neither knows who your users are, whether a token is valid, or how expensive an endpoint is. Treat them as a first layer, not a complete abuse strategy.&lt;/p&gt;

&lt;p&gt;The list below runs roughly from foundational settings to refinements. Behavior described here follows the &lt;a href="https://nginx.org/en/docs/http/ngx_http_limit_req_module.html" rel="noopener noreferrer"&gt;official limit_req module documentation&lt;/a&gt;. Some options depend on the Nginx version: &lt;code&gt;delay=&lt;/code&gt; requires 1.15.7, and &lt;code&gt;limit_req_dry_run&lt;/code&gt; requires 1.17.1. Verify them against the docs for your build. More operations-focused write-ups are on the &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt; home page.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decide the identity first.&lt;/strong&gt; Per-IP is the default fallback. Per-API-key or per-account is better for authenticated traffic. Many clients share one NAT address, so IP-only limits punish offices and mobile carriers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore the real client IP.&lt;/strong&gt; Behind a load balancer or CDN, &lt;code&gt;$remote_addr&lt;/code&gt; is the proxy. Configure &lt;code&gt;set_real_ip_from&lt;/code&gt; and &lt;code&gt;real_ip_header&lt;/code&gt; from the &lt;a href="https://nginx.org/en/docs/http/ngx_http_realip_module.html" rel="noopener noreferrer"&gt;realip module&lt;/a&gt;, and trust only addresses you control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;$binary_remote_addr&lt;/code&gt; as the key.&lt;/strong&gt; It is more compact than &lt;code&gt;$remote_addr&lt;/code&gt;, which matters for zone sizing. According to the documentation, a stored state takes 64 bytes on 32-bit platforms and 128 bytes on 64-bit platforms. One megabyte therefore holds about 16,000 or about 8,000 states. Longer string keys, such as API keys, can use more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size the zone for peak distinct clients, not average.&lt;/strong&gt; When the zone is full, Nginx removes the least recently used state. If it still cannot create a new state, it rejects the request. Plan for attacks that rotate source addresses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set rates per endpoint class.&lt;/strong&gt; Login, token issuance, search, and bulk export deserve separate zones with different limits from cheap reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose burst deliberately.&lt;/strong&gt; &lt;code&gt;burst&lt;/code&gt; sets a queue size. Without &lt;code&gt;nodelay&lt;/code&gt; or &lt;code&gt;delay=&lt;/code&gt;, queued requests are delayed to match the rate, which ties up client connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return 429, not 503.&lt;/strong&gt; Set &lt;code&gt;limit_req_status 429&lt;/code&gt; and &lt;code&gt;limit_conn_status 429&lt;/code&gt;. The default is 503, which monitoring and client retry logic read as a server fault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/strong&gt; Well-behaved clients back off when told how long. Use a named error location so the header and a JSON body are consistent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;limit_conn&lt;/code&gt; for expensive streams.&lt;/strong&gt; Downloads, long polls, and uploads cost connections, not request count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tighten timeouts and sizes.&lt;/strong&gt; Review &lt;code&gt;client_header_timeout&lt;/code&gt;, &lt;code&gt;client_body_timeout&lt;/code&gt;, &lt;code&gt;send_timeout&lt;/code&gt;, &lt;code&gt;keepalive_timeout&lt;/code&gt;, and &lt;code&gt;client_max_body_size&lt;/code&gt; so slow clients cannot hold sockets indefinitely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect the upstream.&lt;/strong&gt; Cap &lt;code&gt;proxy_read_timeout&lt;/code&gt; and limit &lt;code&gt;proxy_next_upstream_tries&lt;/code&gt;, so a rejected or slow call is not retried into a storm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test in dry-run first.&lt;/strong&gt; &lt;code&gt;limit_req_dry_run on&lt;/code&gt; (1.17.1 and later) logs what would be rejected without rejecting it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the http-level setup. It keys on the API key when present and falls back to the IP. It also adds a strict per-IP zone for authentication endpoints and exempts trusted internal ranges from every zone.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# http { } context
# Internal monitors and health checks are exempt from all zones (empty key).
geo $is_trusted {
    default      0;
    10.0.0.0/8   1;
}

# Fall back to client IP when no API key header is sent.
map $http_x_api_key $api_client_raw {
    default $http_x_api_key;
    ""      $binary_remote_addr;
}
map $is_trusted $api_client {
    0 $api_client_raw;
    1 "";
}
map $is_trusted $ip_key {
    0 $binary_remote_addr;
    1 "";
}

limit_req_zone  $api_client zone=api_key:20m rate=20r/s;
limit_req_zone  $ip_key     zone=api_ip:20m  rate=50r/s;
limit_req_zone  $ip_key     zone=auth_ip:10m rate=10r/m;  # per minute for fractional rates
limit_conn_zone $ip_key     zone=conn_ip:10m;

limit_req_status  429;
limit_conn_status 429;
limit_req_log_level warn;

upstream api_backend {
    server 127.0.0.1:8080;   # placeholder backend
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And the server block that applies the zones and answers rejections with a useful body:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;server {
    listen 443 ssl;
    server_name api.example.com;
    ssl_certificate     /etc/nginx/tls/example.crt;   # placeholder paths
    ssl_certificate_key /etc/nginx/tls/example.key;

    error_page 429 = @rate_limited;

    location /v1/ {
        limit_req  zone=api_key burst=40 nodelay;  # absorb short spikes, no queue delay
        limit_req  zone=api_ip  burst=100 nodelay; # exceeding either zone rejects the request
        limit_conn conn_ip 20;
        proxy_pass http://api_backend;
    }

    location = /v1/auth/token {
        limit_req zone=auth_ip burst=5 nodelay;
        proxy_pass http://api_backend;
    }

    location @rate_limited {
        default_type application/json;
        add_header Retry-After 5 always;           # 'always' is needed on error responses
        return 429 '{"error":"rate_limited","retry_after":5}';
    }
}
&lt;/code&gt;&lt;/pre&gt;

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

&lt;p&gt;&lt;strong&gt;Watch out for empty keys.&lt;/strong&gt; The documentation states that requests with an empty key value are not accounted. That is useful for allow-lists, as above. It becomes a hole when a variable you expected to be populated is not. A key built from a header that clients can omit, or from a variable that is only set after authentication, silently disables the limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for unauthenticated key headers.&lt;/strong&gt; In the example, a client can send a fresh random &lt;code&gt;X-Api-Key&lt;/code&gt; on every request and get a fresh bucket each time. That is why the per-IP zone sits alongside it. Only key on identity that Nginx has actually validated, for example through &lt;code&gt;auth_request&lt;/code&gt; or a signed token check. Otherwise, keep the IP zone as the backstop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inheritance surprises.&lt;/strong&gt; &lt;code&gt;limit_req&lt;/code&gt; directives are inherited from the parent level only if the current level defines none of its own. Adding one &lt;code&gt;limit_req&lt;/code&gt; in a location can drop the server-level limits for that location. Run &lt;code&gt;nginx -T&lt;/code&gt; and read the effective config per location.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-instance state.&lt;/strong&gt; Zones live in shared memory on one host. With several Nginx nodes behind a balancer, each node enforces its own limit. A single client can therefore get up to roughly the limit times the node count, depending on how the balancer spreads its requests. If you need a true global quota, that belongs in an API gateway or a shared store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IPv6.&lt;/strong&gt; Attackers with a /64 can rotate through enormous numbers of addresses, and per-address limits then do little. Aggregating the key by prefix needs a regex &lt;code&gt;map&lt;/code&gt; on the address, so check how your traffic looks before relying on IP keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries from your own clients.&lt;/strong&gt; SDKs that retry on 429 without jitter, or without honoring &lt;code&gt;Retry-After&lt;/code&gt;, turn a limit into a synchronized wave. Document the header in your API reference.&lt;/p&gt;

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

&lt;p&gt;Keep limits in version control and treat them as code. Generate the &lt;code&gt;limit_req_zone&lt;/code&gt; lines from a small YAML file of endpoint classes and rates, and render them with your configuration tool. Run &lt;code&gt;nginx -t&lt;/code&gt; in CI before any deploy. A failing syntax check is cheap to catch there and expensive on a production reload.&lt;/p&gt;

&lt;p&gt;Roll out new limits in stages. Enable &lt;code&gt;limit_req_dry_run on&lt;/code&gt; where your version supports it (1.17.1 and later). Watch the log for what would have been rejected, then switch dry-run off.&lt;/p&gt;

&lt;p&gt;Add a post-deploy smoke test that confirms the limit actually fires. A parallel burst against a non-production endpoint should return some 429 responses that carry a &lt;code&gt;Retry-After&lt;/code&gt; header. Run it from an address outside the trusted range, or the exemption will hide the limit.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/usr/bin/env bash
# Fire a parallel burst; require 429s that carry Retry-After.
# %header{} in -w needs curl 7.84.0 or later.
URL="https://staging-api.example.com/v1/ping"
results=$(seq 1 200 | xargs -P 20 -I{} \
  curl -s -o /dev/null -w '%{http_code} %header{retry-after}\n' "$URL")
echo "$results" | sort | uniq -c
echo "$results" | grep -q '^429' || { echo "FAIL: no 429 observed"; exit 1; }
echo "$results" | grep -Eq '^429 [0-9]+' || { echo "FAIL: 429 without Retry-After"; exit 1; }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For monitoring, use a JSON &lt;code&gt;log_format&lt;/code&gt; that includes &lt;code&gt;$status&lt;/code&gt;, &lt;code&gt;$limit_req_status&lt;/code&gt; (1.17.6 and later), the client key, and the upstream response time. Export the 429 rate per endpoint to your metrics system and alert on sudden changes in either direction. A spike means abuse or a misbehaving client. A drop to zero after a deploy may mean the limit was lost to inheritance.&lt;/p&gt;

&lt;p&gt;Finally, review the numbers periodically. Limits chosen at launch rarely match real usage, and the useful signal is which legitimate clients touch them. Adjust zones from the logs, not from guesses.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/03/pgvector-semantic-search-in-postgresql-a-python-checklist/" rel="noopener noreferrer"&gt;pgvector Semantic Search in PostgreSQL: A Python Checklist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/13/nginx-proxy-acme-companion-why-certs-never-get-issued/" rel="noopener noreferrer"&gt;nginx-proxy acme-companion: why certs never get issued&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/12/nginx-tls-hardening-manual-directives-vs-delegated-edge-policy/" rel="noopener noreferrer"&gt;Nginx TLS Hardening: Manual Directives vs Delegated Edge Policy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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