<?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>Terraform Vault Secrets: Keep Them Out of State and Git</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sat, 17 Oct 2026 07:01:59 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/terraform-vault-secrets-keep-them-out-of-state-and-git-5095</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/terraform-vault-secrets-keep-them-out-of-state-and-git-5095</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/17/terraform-vault-secrets-keep-them-out-of-state-and-git" 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 team's Terraform repo needs a database password. The quickest way to get one into the config is a &lt;code&gt;terraform.tfvars&lt;/code&gt; file that someone pasted into a CI variable. Handling Terraform Vault secrets properly means the password lives in HashiCorp Vault, Terraform fetches it at apply time, and nothing sensitive is committed. That is the goal, but it has one catch worth understanding up front.&lt;/p&gt;

&lt;p&gt;The classic approach is the &lt;code&gt;vault_kv_secret_v2&lt;/code&gt; data source. It works, but values read through data sources are persisted in Terraform state in plain text, and this is documented behavior. Marking a variable &lt;code&gt;sensitive&lt;/code&gt; only hides it from CLI output. So the secret leaves Vault and ends up in whatever backend stores your state.&lt;/p&gt;

&lt;p&gt;Newer Terraform and Vault provider releases close that gap with ephemeral resources and write-only arguments. These values exist during a run and are never written to plan or state. This guide builds that pattern end to end. It also shows the fallback when your versions or target resources don't support it yet.&lt;/p&gt;

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

&lt;p&gt;You need a reachable Vault server with a KV version 2 secrets engine mounted (the dev-mode default is &lt;code&gt;secret/&lt;/code&gt;) and a way to authenticate non-interactively.&lt;/p&gt;

&lt;p&gt;Ephemeral resources require Terraform 1.10 or later, and write-only arguments require 1.11 or later. The Vault provider needs a release that ships the &lt;code&gt;vault_kv_secret_v2&lt;/code&gt; ephemeral resource. Pinning the current 5.x line is a safe default, but confirm the exact minimum in the provider changelog. Verify the details in the &lt;a href="https://developer.hashicorp.com/terraform/language/resources/ephemeral" rel="noopener noreferrer"&gt;Terraform ephemeral resources documentation&lt;/a&gt; and the Vault provider registry page, because they move between releases.&lt;/p&gt;

&lt;p&gt;You also need a target resource that exposes a write-only argument for the secret. Many providers have added these gradually, for example &lt;code&gt;password_wo&lt;/code&gt; on the AWS provider's database instance resource. Check your provider's resource documentation rather than assuming support.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Terraform 1.11+ (or 1.10+ if you only use ephemeral values in provider blocks and other ephemeral contexts)&lt;/li&gt;
&lt;li&gt;A KV v2 secret, for example at &lt;code&gt;secret/app/db&lt;/code&gt; with a &lt;code&gt;password&lt;/code&gt; key&lt;/li&gt;
&lt;li&gt;A Vault auth method for CI, such as AppRole or JWT/OIDC, not a long-lived root token&lt;/li&gt;
&lt;li&gt;A remote state backend with encryption and restricted access, as a baseline regardless&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already run keyless CI, the same idea applies here. The &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS archive&lt;/a&gt; covers OIDC federation for CI, which pairs well with Vault's JWT auth.&lt;/p&gt;

&lt;h2&gt;Step 1: Configure the provider without hard-coded credentials&lt;/h2&gt;



&lt;p&gt;Keep the provider block free of tokens. The Vault provider reads &lt;code&gt;VAULT_ADDR&lt;/code&gt; and &lt;code&gt;VAULT_TOKEN&lt;/code&gt; from the environment. It also supports &lt;code&gt;auth_login*&lt;/code&gt; blocks for AppRole, JWT, and other methods, which are listed in the provider documentation on developer.hashicorp.com.&lt;/p&gt;

&lt;p&gt;The following provider block sets only the address and one behavior flag worth understanding.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;terraform {
  required_version = "&amp;gt;= 1.11"
  required_providers {
    vault = {
      source  = "hashicorp/vault"
      version = "~&amp;gt; 5.0" # confirm the ephemeral-capable version in the registry
    }
  }
}

provider "vault" {
  address = var.vault_address # or set VAULT_ADDR in the environment

  # By default the provider requests a child token, which needs
  # permission on auth/token/create. This example skips that and uses
  # the supplied token directly; remove this line if your policy
  # allows token creation and you want a short-lived child token.
  skip_child_token = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for the child token default.&lt;/strong&gt; A typical failure is a 403 on &lt;code&gt;auth/token/create&lt;/code&gt; when the CI policy is tightly scoped. You have two documented options. You can grant that capability and drop the flag, or keep &lt;code&gt;skip_child_token = true&lt;/code&gt; as shown, in which case the CI token's own TTL is what limits exposure.&lt;/p&gt;

&lt;h2&gt;Step 2: Write a least-privilege Vault policy&lt;/h2&gt;

&lt;p&gt;The identity Terraform uses should read only the paths the configuration needs. With KV v2, the API path differs from the CLI path. The policy must target &lt;code&gt;secret/data/...&lt;/code&gt;, not &lt;code&gt;secret/...&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# terraform-ci.hcl: apply with: vault policy write terraform-ci terraform-ci.hcl
path "secret/data/app/db" {
  capabilities = ["read"] # KV v2 reads go through /data/
}

path "secret/metadata/app/db" {
  capabilities = ["read"] # only needed if you read version metadata
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for the missing &lt;code&gt;data/&lt;/code&gt; segment.&lt;/strong&gt; A policy written for &lt;code&gt;secret/app/db&lt;/code&gt; looks correct but still returns permission denied on KV v2. Bind the policy to your AppRole or JWT role. Keep token TTLs short enough that a leaked CI token expires quickly.&lt;/p&gt;

&lt;h2&gt;Step 3: Read Terraform Vault secrets as ephemeral values&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;ephemeral&lt;/code&gt; block fetches the secret during plan and apply but never records it. You can reference it only in places that accept ephemeral values: write-only arguments, provider configuration, and other ephemeral contexts. Terraform rejects references from ordinary attributes at validation time, which is the safety net you want.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;ephemeral "vault_kv_secret_v2" "db" {
  mount = "secret"
  name  = "app/db"
}

resource "aws_db_instance" "app" {
  identifier        = "app-db"
  engine            = "postgres"
  instance_class    = "db.t4g.micro"
  allocated_storage = 20
  username          = "app_admin"

  # Write-only: sent to the API, never stored in plan or state
  password_wo         = ephemeral.vault_kv_secret_v2.db.data["password"]
  password_wo_version = 1 # bump this number to trigger a password update
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Because write-only values are not stored, Terraform cannot detect when they change. The companion &lt;code&gt;_version&lt;/code&gt; argument is the documented mechanism: increment it when the secret rotates in Vault. Confirm the attribute names for your provider versions, since the ephemeral resource's output shape (&lt;code&gt;data&lt;/code&gt; versus &lt;code&gt;data_json&lt;/code&gt;) is version-dependent.&lt;/p&gt;

&lt;h2&gt;Step 4: Plan the fallback for unsupported resources&lt;/h2&gt;

&lt;p&gt;Not every resource has a write-only argument yet. When you must use the &lt;code&gt;vault_kv_secret_v2&lt;/code&gt; data source, the secret will be in state, so treat state as a secret store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use an encrypted remote backend.&lt;/li&gt;
&lt;li&gt;Limit read access to the state to the same people who can read the secret.&lt;/li&gt;
&lt;li&gt;If the backend is an object store such as S3 or GCS, enable versioning and access logging on the bucket.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prefer dynamic secrets where possible. Vault's database and AWS secrets engines issue short-lived credentials, so a value captured in state expires on its own. That limits the damage even when the pattern isn't ideal.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Fallback: value WILL be stored in state. Protect the backend accordingly.
data "vault_kv_secret_v2" "legacy" {
  mount = "secret"
  name  = "app/legacy"
}

# Reference as data.vault_kv_secret_v2.legacy.data["api_key"]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Also avoid managing the secret's value itself with &lt;code&gt;vault_kv_secret_v2&lt;/code&gt; as a resource unless you accept it in state. Recent provider releases offer a write-only &lt;code&gt;data_json_wo&lt;/code&gt; argument for that case; check the resource documentation for your version.&lt;/p&gt;

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

&lt;p&gt;Start by proving that the identity can read the path and nothing broader. Then confirm the secret is absent from state. Use a throwaway secret value for this test so a failure costs nothing.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# 1. Confirm the policy grants what you expect (note the /data/ path)
vault token capabilities secret/data/app/db

# 2. Validate and plan; a reference from a normal attribute fails here
terraform validate
terraform plan -out=tfplan

# 3. Apply, then search state and plan for the secret value
terraform apply tfplan
terraform state pull | grep -c "your-test-password"   # expect 0 with write-only args
terraform show -json tfplan | grep -c "your-test-password"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;With the ephemeral and write-only path, both counts should be zero. A non-zero count means something is still reading the value through a data source or a regular attribute. Run &lt;code&gt;terraform state list&lt;/code&gt; and look for &lt;code&gt;data.vault_kv_secret_v2&lt;/code&gt; entries.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rotate the secret in Vault, bump &lt;code&gt;password_wo_version&lt;/code&gt;, and confirm the plan shows an in-place update.&lt;/li&gt;
&lt;li&gt;Revoke the CI token and confirm the run fails with a clear permission error rather than a silent default.&lt;/li&gt;
&lt;li&gt;If an audit device is enabled, check Vault's audit log for the read. It gives you evidence of who fetched what and when.&lt;/li&gt;
&lt;li&gt;Grep the repository and CI variables for leftover &lt;code&gt;.tfvars&lt;/code&gt; files with the old password.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Getting Terraform Vault secrets right comes down to three habits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authenticate with short-lived identities.&lt;/li&gt;
&lt;li&gt;Scope policies to exact KV v2 paths.&lt;/li&gt;
&lt;li&gt;Prefer ephemeral resources with write-only arguments so values never reach state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a resource doesn't support that yet, treat the state backend as part of your secret store and favor dynamic credentials that expire. Ephemeral support and attribute names change between Terraform and provider releases. Before you upgrade, check the &lt;a href="https://developer.hashicorp.com/terraform/docs" rel="noopener noreferrer"&gt;official Terraform documentation&lt;/a&gt; and the Vault provider registry page, and rerun the state-grep check after every provider bump.&lt;/p&gt;

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

&lt;ul&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;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/14/terraform-drift-detection-in-ci-building-a-remediation-pipeline/" rel="noopener noreferrer"&gt;Terraform Drift Detection in CI: Building a Remediation Pipeline&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Ansible server bootstrap locks you out of new hosts: fix</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Fri, 16 Oct 2026 07:01:57 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/ansible-server-bootstrap-locks-you-out-of-new-hosts-fix-48ja</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/ansible-server-bootstrap-locks-you-out-of-new-hosts-fix-48ja</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/16/ansible-server-bootstrap-locks-you-out-of-new-hosts-fix" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;Failure scenario: an Ansible server bootstrap that locks you out&lt;/h2&gt;



&lt;p&gt;A typical Ansible server bootstrap goes wrong like this. A team writes one playbook that runs against a fresh cloud VM as &lt;code&gt;root&lt;/code&gt;. It creates a &lt;code&gt;deploy&lt;/code&gt; user, turns on the firewall, and disables root and password logins in &lt;code&gt;sshd&lt;/code&gt;. On the first host it appears to work. On the next one, the play stops halfway with &lt;code&gt;UNREACHABLE&lt;/code&gt;, and every later attempt fails with &lt;code&gt;Permission denied (publickey)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first attempt on a new host behaves differently from every re-run. The root session that started the play is already authenticated, so it survives the sshd restart. A fresh connection is rejected, because root is now disabled and the new user's key may never have been installed correctly.&lt;/p&gt;

&lt;p&gt;The playbook below is the shape that produces this failure. It is a composite for illustration, not a recording of a real incident.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;---
# ANTI-PATTERN: one play, one connection user, hardening mixed with account setup
- name: Bootstrap server
  hosts: new_servers
  remote_user: root          # inventory also says ansible_user=root, forever
  tasks:
    - name: Create deploy user
      ansible.builtin.user:
        name: deploy
        groups: sudo

    - name: Disable root and password login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^#?{{ item.key }}"
        line: "{{ item.key }} {{ item.value }}"
      loop:
        - { key: PermitRootLogin, value: "no" }
        - { key: PasswordAuthentication, value: "no" }
      notify: restart ssh

    - name: Enable firewall
      community.general.ufw:
        state: enabled          # default policy may deny port 22 if no allow rule exists yet

  handlers:
    - name: restart ssh
      ansible.builtin.service:
        name: sshd
        state: restarted
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Nothing in this file is syntactically wrong, which is why it passes review. The problems are in the ordering and in what is never checked.&lt;/p&gt;

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

&lt;p&gt;Several independent causes combine here, and any one of them is enough to lock you out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The authorized key is never installed.&lt;/strong&gt; The &lt;code&gt;deploy&lt;/code&gt; user is created, but no task places a public key in its &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;. Once root login and passwords are off, no valid credential remains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The firewall can cut off port 22.&lt;/strong&gt; With the UFW module, enabling the firewall with a default deny incoming policy and no allow rule for SSH drops new connections. The module's documented behavior is to apply exactly what you ask, so a missing allow rule is your mistake, not a bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for drop-in precedence.&lt;/strong&gt; Current Debian and Ubuntu images ship an &lt;code&gt;Include /etc/ssh/sshd_config.d/*.conf&lt;/code&gt; line at the top of &lt;code&gt;sshd_config&lt;/code&gt;. For most sshd keywords the first value read wins. A cloud-init file such as &lt;code&gt;50-cloud-init.conf&lt;/code&gt; that sets &lt;code&gt;PasswordAuthentication yes&lt;/code&gt; can therefore override your edit at the bottom of the main file. You may think you hardened the host when you did not. Verify the effective values with &lt;code&gt;sshd -T&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are also three smaller problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Handlers run at the end of the play. If any later task fails, the restart is skipped and the host sits in an inconsistent state until you rerun.&lt;/li&gt;
&lt;li&gt;Minimal images may have no Python, so fact gathering fails before your first task.&lt;/li&gt;
&lt;li&gt;The service is named &lt;code&gt;ssh&lt;/code&gt; on Debian-family systems and &lt;code&gt;sshd&lt;/code&gt; on RHEL-family ones. Some recent Ubuntu releases also use socket activation, so check how your target restarts the daemon.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;The fix: split the bootstrap into verified phases&lt;/h2&gt;

&lt;p&gt;The principle is simple: never remove an access path until you have proven the replacement works. The playbook below uses one play to create access. A second play connects as the new user and does the hardening.&lt;/p&gt;

&lt;p&gt;Phase 1 connects with the image's initial credentials and creates the admin user, its key, and sudo rights.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;---
- name: Phase 1 - create admin access
  hosts: new_servers
  gather_facts: false
  vars:
    ansible_user: root                      # play vars override inventory for this play only
  tasks:
    - name: Ensure Python exists (minimal Debian/Ubuntu images; use dnf on RHEL-family)
      ansible.builtin.raw: test -x /usr/bin/python3 || (apt-get update &amp;amp;&amp;amp; apt-get install -y python3)
      changed_when: false

    - name: Gather facts now that Python is present
      ansible.builtin.setup:

    - name: Create deploy user
      ansible.builtin.user:
        name: deploy
        shell: /bin/bash
        create_home: true

    - name: Install public key
      ansible.posix.authorized_key:
        user: deploy
        key: "{{ lookup('ansible.builtin.file', 'files/deploy.pub') }}"
        exclusive: true                     # removes stray keys; remove this line if you manage keys elsewhere

    - name: Passwordless sudo, validated before it lands
      ansible.builtin.copy:
        dest: /etc/sudoers.d/90-deploy
        content: "deploy ALL=(ALL) NOPASSWD:ALL\n"
        mode: "0440"
        validate: /usr/sbin/visudo -cf %s   # a broken sudoers file is its own lockout
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Phase 2 connects as &lt;code&gt;deploy&lt;/code&gt;. If the key or sudo is wrong, the play fails here, while root access still works.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Phase 2 - verify access, then harden
  hosts: new_servers
  become: true
  vars:
    ansible_user: deploy
  tasks:
    - name: Prove key login and sudo work (fails before anything is locked down)
      ansible.builtin.command: id -u
      register: uid
      changed_when: false
      failed_when: uid.stdout != "0"

    - name: Allow SSH BEFORE enabling the firewall
      community.general.ufw:
        rule: allow
        port: "22"
        proto: tcp

    - name: Enable firewall
      community.general.ufw:
        state: enabled
        policy: deny
        direction: incoming

    - name: SSH hardening drop-in (00- sorts before 50-cloud-init.conf)
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/00-hardening.conf
        content: |
          PermitRootLogin no
          PasswordAuthentication no
        mode: "0644"
      notify: reload ssh

    - name: Test full sshd config before any reload
      ansible.builtin.command: /usr/sbin/sshd -t
      changed_when: false

    - name: Apply now, not at end of play
      ansible.builtin.meta: flush_handlers

    - name: Drop the multiplexed session so the next task opens a fresh SSH login
      ansible.builtin.meta: reset_connection

    - name: Confirm a new key-based connection still works
      ansible.builtin.wait_for_connection:
        timeout: 30

  handlers:
    - name: reload ssh
      ansible.builtin.service:
        name: "{{ 'ssh' if ansible_os_family == 'Debian' else 'sshd' }}"
        state: reloaded
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;reset_connection&lt;/code&gt; step matters. Ansible normally reuses a persistent SSH control connection, so without it &lt;code&gt;wait_for_connection&lt;/code&gt; could succeed over the old session and prove nothing. Also note that if &lt;code&gt;sshd -t&lt;/code&gt; fails, the play stops before the reload, but the bad drop-in stays on disk. Fix or remove it before anything restarts sshd or reboots the host.&lt;/p&gt;

&lt;p&gt;Once the host is hardened, rerunning is safe if you target only the second play, for example with a tag or a separate inventory group. Keep the root-based play restricted to hosts that have never been bootstrapped. The module names above are from the current &lt;code&gt;ansible.builtin&lt;/code&gt;, &lt;code&gt;ansible.posix&lt;/code&gt;, and &lt;code&gt;community.general&lt;/code&gt; collections. Check the &lt;a href="https://docs.ansible.com/ansible/latest/collections/index.html" rel="noopener noreferrer"&gt;Ansible collections documentation&lt;/a&gt; for your installed versions and parameter details.&lt;/p&gt;

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

&lt;p&gt;Before the bootstrap playbook goes near a real fleet, run through the points below. Most of them are cheap to automate in CI against a throwaway VM.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[ ] Access first, hardening second: separate plays, separate connection users
[ ] A task proves key login AND sudo work as the new user before hardening
[ ] Firewall: allow 22/tcp (or your SSH port) before enabling; default deny after
[ ] sshd changes live in a drop-in named to sort before cloud-init files
[ ] sshd -t runs before every reload; sudoers uses validate: visudo -cf %s
[ ] Handlers flushed explicitly (meta: flush_handlers) when order matters
[ ] meta: reset_connection before verifying reconnection, so the check uses a fresh login
[ ] Service name handled per OS family (ssh vs sshd); socket activation checked
[ ] Python bootstrap via raw for minimal images, with changed_when: false
[ ] Effective config verified with: sshd -T | grep -Ei 'permitrootlogin|passwordauth'
[ ] Provider console / serial access or a rescue snapshot confirmed before the first run
[ ] Tested on a disposable host: real run first, then a second run (--check --diff is only
    meaningful after the first real run, since Phase 2 needs the deploy user to exist)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for the second run.&lt;/strong&gt; A bootstrap playbook that only succeeds once is a script, not automation. Run it twice against the same disposable host. The second pass of the hardening play should report no changes. If it cannot even connect, the inventory still assumes root access that no longer exists.&lt;/p&gt;

&lt;p&gt;Keep one out-of-band recovery route, such as the provider's web console or a rescue mode. Confirm it works before you automate anything that touches SSH or the firewall. It will not prevent the mistake, but it turns a rebuild into a quick repair. For more infrastructure automation patterns, browse the rest of &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A reliable Ansible server bootstrap is mostly about ordering and proof: create access, test it from a fresh connection, and only then close the old doors.&lt;/p&gt;

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

&lt;ul&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/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/19/wordpress-performance-profiling-with-php-fpm-status-and-slow-logs/" rel="noopener noreferrer"&gt;WordPress performance profiling with php-fpm status and slow logs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Helm Chart Values for Staging vs Production Done Right</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:02:05 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/helm-chart-values-for-staging-vs-production-done-right-30pc</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/helm-chart-values-for-staging-vs-production-done-right-30pc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/15/helm-chart-values-for-staging-vs-production-done-right" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A deploy goes green in staging, then production comes up with one replica, a debug log level, and an ingress host that points at the wrong environment. Nobody changed the chart. The Helm chart values were assembled differently, and nobody could say exactly how. This is a common way environment drift shows up. It is usually a misunderstanding of how Helm merges values rather than a Helm bug.&lt;/p&gt;

&lt;h2&gt;What Helm chart values actually do&lt;/h2&gt;



&lt;p&gt;Values are the input to the chart's Go templates. Helm builds a single values object from several sources and renders the templates against it. The documented precedence, lowest to highest, is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the chart's own &lt;code&gt;values.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;values from a parent chart&lt;/li&gt;
&lt;li&gt;files passed with &lt;code&gt;-f&lt;/code&gt; / &lt;code&gt;--values&lt;/code&gt; (later files win)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--set&lt;/code&gt;, &lt;code&gt;--set-string&lt;/code&gt;, and &lt;code&gt;--set-file&lt;/code&gt; flags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Maps are merged key by key, so an environment file only needs to contain what differs. &lt;strong&gt;Lists are not merged. A list in a later source replaces the earlier list entirely.&lt;/strong&gt; That one rule explains many "why did my tolerations vanish" tickets.&lt;/p&gt;

&lt;p&gt;Setting a key to &lt;code&gt;null&lt;/code&gt; in an override file removes the default key. This is useful for dropping a default resource limit or annotation.&lt;/p&gt;

&lt;p&gt;Helm stores the user-supplied values with each release, alongside the chart.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;helm get values&lt;/code&gt; shows the values you supplied.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;helm get values --all&lt;/code&gt; shows the full merged result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the first commands to run when two environments disagree. Details are in the &lt;a href="https://helm.sh/docs/chart_template_guide/values_files/" rel="noopener noreferrer"&gt;official Helm values documentation&lt;/a&gt;. Verify the exact behavior against the Helm version you run, since Helm 3 and Helm 4 differ in some details.&lt;/p&gt;

&lt;h2&gt;How people use Helm chart values wrong&lt;/h2&gt;

&lt;p&gt;The first pattern is copying the entire &lt;code&gt;values.yaml&lt;/code&gt; into &lt;code&gt;values-staging.yaml&lt;/code&gt; and &lt;code&gt;values-production.yaml&lt;/code&gt;. Within a few releases, someone adds a key to one file and not the others. The files stop being overrides and become three divergent configurations.&lt;/p&gt;

&lt;p&gt;The second is environment logic inside templates, such as &lt;code&gt;{{ if eq .Values.env "production" }}&lt;/code&gt;. It looks convenient, but the chart now encodes knowledge about your environments. Staging can no longer exercise the same code path as production, and every new environment needs a template change.&lt;/p&gt;

&lt;p&gt;The third is pipeline-side &lt;code&gt;--set&lt;/code&gt; flags. A CI job with a dozen &lt;code&gt;--set&lt;/code&gt; arguments is configuration that lives outside version control review.&lt;/p&gt;

&lt;p&gt;Watch out for type coercion here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--set enabled=true&lt;/code&gt; becomes a boolean.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--set image.tag=20240101&lt;/code&gt; becomes an integer. Use &lt;code&gt;--set-string&lt;/code&gt; when the value must stay a string.&lt;/li&gt;
&lt;li&gt;Values files have the same trap through YAML parsing. An unquoted &lt;code&gt;version: 1.10&lt;/code&gt; is read as the float 1.1, so quote version-like strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other common failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overriding a list (hosts, env entries, tolerations) and losing the defaults.&lt;/li&gt;
&lt;li&gt;Putting secrets in plain values files committed to Git.&lt;/li&gt;
&lt;li&gt;Using &lt;code&gt;image.tag: latest&lt;/code&gt; in either environment. This makes a rollback unreliable, because the same tag can point at different images.&lt;/li&gt;
&lt;li&gt;Skipping &lt;code&gt;helm template&lt;/code&gt; or &lt;code&gt;helm lint&lt;/code&gt; and discovering a missing key only at install time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;The correct approach to Helm chart values&lt;/h2&gt;

&lt;p&gt;Treat &lt;code&gt;values.yaml&lt;/code&gt; as safe, production-shaped defaults, and keep each environment file as a small diff on top. Staging should differ from production in scale and endpoints, not in structure. The templates stay identical across environments.&lt;/p&gt;

&lt;p&gt;Here is a minimal layout. The base file holds defaults, and the environment files carry only differences:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# values.yaml (chart defaults, safe and production-shaped)
replicaCount: 3
image:
  repository: registry.example.com/shop/api
  tag: ""            # set at deploy time, never "latest"
resources:
  requests: { cpu: 250m, memory: 256Mi }
  limits: { memory: 512Mi }
ingress:
  enabled: true
  host: api.example.com
logLevel: info

# values-staging.yaml (only what differs)
replicaCount: 1
resources:
  requests: { cpu: 50m, memory: 128Mi }
ingress:
  host: api.staging.example.com
logLevel: debug
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Deploy by stacking files in a fixed order. The pipeline selects the environment file and passes the image reference, and nothing else:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# render first; fails fast on template and schema errors
helm template shop ./chart -f values-staging.yaml --set-string image.tag="$IMAGE_TAG" &amp;gt; /dev/null

# chart values.yaml is applied automatically; the -f file overrides it,
# and any additional -f files would override in the order given
helm upgrade --install shop ./chart \
  --namespace shop-staging --create-namespace \
  -f values-staging.yaml \
  --set-string image.tag="$IMAGE_TAG" \
  --atomic --wait --timeout 5m   # roll back automatically on failure
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In Helm 3, &lt;code&gt;--atomic&lt;/code&gt; already implies &lt;code&gt;--wait&lt;/code&gt;. In Helm 4 it is renamed to &lt;code&gt;--rollback-on-failure&lt;/code&gt;, so adjust the flag to match your client version.&lt;/p&gt;

&lt;p&gt;Add a &lt;code&gt;values.schema.json&lt;/code&gt; to the chart. Helm validates merged values against it on &lt;code&gt;install&lt;/code&gt;, &lt;code&gt;upgrade&lt;/code&gt;, &lt;code&gt;lint&lt;/code&gt;, and &lt;code&gt;template&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A string where an integer belongs fails before anything reaches the cluster.&lt;/li&gt;
&lt;li&gt;A typo like &lt;code&gt;replicaCout&lt;/code&gt; is only rejected if the schema sets &lt;code&gt;"additionalProperties": false&lt;/code&gt; for that object.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Once the basic layering works, a few patterns reduce risk further. Which one fits depends on how many environments and teams you have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layered files per dimension.&lt;/strong&gt; Instead of one file per environment, split by concern: &lt;code&gt;values-common.yaml&lt;/code&gt;, &lt;code&gt;values-prod.yaml&lt;/code&gt;, &lt;code&gt;values-eu-west.yaml&lt;/code&gt;. Pass them in order of increasing specificity. This scales to multiple clusters and regions without duplicating shared settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Convert lists to maps where you control the chart.&lt;/strong&gt; Since lists are replaced wholesale, a map such as &lt;code&gt;env: { LOG_FORMAT: json }&lt;/code&gt; can be overridden one key at a time. Templates can iterate the map with &lt;code&gt;range $k, $v := .Values.env&lt;/code&gt;. Map iteration in Go templates is sorted by key, so output stays deterministic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets outside values.&lt;/strong&gt; Reference Kubernetes Secrets by name in values and populate them with an external secrets operator. Alternatively, encrypt value files with SOPS through a plugin such as helm-secrets. Check the plugin's current maintenance status before adopting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitOps tools.&lt;/strong&gt; Argo CD supports multiple value files per application source. Flux's HelmRelease supports &lt;code&gt;valuesFrom&lt;/code&gt; and inline values. In both, the environment is declared in Git and reviewed like code. Verify the field names against your installed version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review the diff, not the values.&lt;/strong&gt; The &lt;code&gt;helm diff upgrade&lt;/code&gt; plugin shows the rendered manifest change for a release. A one-line values change that rewrites a StatefulSet's volume template is far easier to catch there than in a values pull request.&lt;/p&gt;

&lt;p&gt;Another gotcha: &lt;strong&gt;subchart values live under the subchart's name&lt;/strong&gt;. A typo in that key is silently ignored by Helm unless a schema rejects unknown keys. Use &lt;code&gt;global:&lt;/code&gt; only for values that genuinely apply to every subchart.&lt;/p&gt;

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

&lt;p&gt;Helm rendering itself is rarely the bottleneck. Template rendering is local and usually finishes quickly even for large charts. Very heavy use of &lt;code&gt;tpl&lt;/code&gt;, nested &lt;code&gt;include&lt;/code&gt;, or huge values trees can slow it. Measure with &lt;code&gt;time helm template&lt;/code&gt; before optimizing.&lt;/p&gt;

&lt;p&gt;The costs that matter are at runtime and in release storage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Release size.&lt;/strong&gt; Helm stores each release revision in a Secret by default, and Kubernetes caps a single Secret at 1 MiB. Helm compresses the release data, but large embedded files in values or templates can still hit that limit. Use &lt;code&gt;--history-max&lt;/code&gt; (default 10 on &lt;code&gt;helm upgrade&lt;/code&gt;) to cap retained revisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollout time.&lt;/strong&gt; &lt;code&gt;--wait&lt;/code&gt; blocks until resources are ready. Your &lt;code&gt;--timeout&lt;/code&gt; should reflect readiness probe settings and image pull times, not a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource requests.&lt;/strong&gt; Cutting staging requests saves cost, but extreme differences hide problems. A service that is never CPU-throttled in staging may be throttled in production. See the &lt;a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/" rel="noopener noreferrer"&gt;Kubernetes resource management docs&lt;/a&gt; for how requests and limits affect scheduling and throttling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replica parity.&lt;/strong&gt; One staging replica is unlikely to reveal rolling-update or PodDisruptionBudget problems. If those matter, run at least two replicas in staging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule is to keep environment differences few, explicit, and reviewable. For more infrastructure patterns, browse the rest of &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/21/irsa-explained-how-eks-pods-borrow-aws-permissions/" rel="noopener noreferrer"&gt;IRSA explained: how EKS pods borrow AWS permissions&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;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
    <item>
      <title>ECR Image Scan Gate in GitLab CI Passes Vulnerable Images</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Wed, 14 Oct 2026 07:02:09 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/ecr-image-scan-gate-in-gitlab-ci-passes-vulnerable-images-4fb5</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/ecr-image-scan-gate-in-gitlab-ci-passes-vulnerable-images-4fb5</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/14/ecr-image-scan-gate-in-gitlab-ci-passes-vulnerable-images" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A merge request goes green, the image is deployed, and a day later someone opens the ECR console and finds a critical CVE on that exact tag. The pipeline had an ECR image scan gate, and it reported success. This runbook covers why a gate like that passes when it should not, and how to rebuild it so it fails closed.&lt;/p&gt;

&lt;h2&gt;Symptoms&lt;/h2&gt;



&lt;p&gt;These are the typical signs of a gate that fails open. You may see one or several.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The gate job is green, but &lt;code&gt;aws ecr describe-image-scan-findings&lt;/code&gt; run by hand shows CRITICAL or HIGH findings for the same image.&lt;/li&gt;
&lt;li&gt;The gate passes on fresh pushes and only "works" on re-runs, when the scan has had time to finish.&lt;/li&gt;
&lt;li&gt;The job log shows an empty or &lt;code&gt;None&lt;/code&gt; severity count, and the script treats that as zero.&lt;/li&gt;
&lt;li&gt;Intermittent &lt;code&gt;ScanNotFoundException&lt;/code&gt; or &lt;code&gt;LimitExceededException&lt;/code&gt; errors appear, and someone added &lt;code&gt;|| true&lt;/code&gt; to quiet them.&lt;/li&gt;
&lt;li&gt;The scanned tag (&lt;code&gt;latest&lt;/code&gt;, or a branch name) points at a different image by the time the deploy job runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; a green job with no printed counts. If the log never shows a number, the gate did not check anything.&lt;/p&gt;

&lt;h2&gt;Root cause&lt;/h2&gt;

&lt;p&gt;ECR scans are asynchronous. A push triggers a scan (if scan-on-push is enabled), but the results arrive later. If the gate queries findings immediately, the response either errors (typically &lt;code&gt;ScanNotFoundException&lt;/code&gt;) or returns a status such as &lt;code&gt;IN_PROGRESS&lt;/code&gt; with no &lt;code&gt;findingSeverityCounts&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;The severity counts map also omits severities with zero findings. A naive query for &lt;code&gt;.CRITICAL&lt;/code&gt; therefore returns &lt;code&gt;null&lt;/code&gt; (or &lt;code&gt;None&lt;/code&gt; with &lt;code&gt;--output text&lt;/code&gt;) in two very different situations: the scan is clean, or the scan has not finished. A script that treats "empty" as "zero" cannot tell these apart, so it passes both.&lt;/p&gt;

&lt;p&gt;Three secondary causes make this worse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Swallowed errors.&lt;/strong&gt; &lt;code&gt;|| true&lt;/code&gt; or a missing &lt;code&gt;set -e&lt;/code&gt; turns an API failure into a pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutable tags.&lt;/strong&gt; The gate scans &lt;code&gt;repo:latest&lt;/code&gt;, but the deploy job pulls whatever that tag points at later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan type differences.&lt;/strong&gt; Basic scanning (on push or manual) and enhanced scanning (Amazon Inspector, continuous) report different statuses and have different manual-scan behavior. The applicable limits and status values depend on how scanning is configured for your registry, so check the &lt;a href="https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning.html" rel="noopener noreferrer"&gt;ECR image scanning documentation&lt;/a&gt; for your setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fixing the ECR image scan gate means removing every path where "no data" becomes "no findings".&lt;/p&gt;

&lt;h2&gt;Fix #1: Wait for a terminal scan status, then fail closed&lt;/h2&gt;

&lt;p&gt;Poll the scan status by image digest, and exit non-zero on anything that is not a completed scan. The job below uses only the AWS CLI's built-in &lt;code&gt;--query&lt;/code&gt;, so it needs no &lt;code&gt;jq&lt;/code&gt; in the image. Only "scan not found yet" is treated as retryable. Any other API error, such as &lt;code&gt;AccessDenied&lt;/code&gt;, fails the job immediately.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;ecr-scan-gate:
  stage: verify
  image:
    name: amazon/aws-cli:latest   # pin a specific version tag in real pipelines
    entrypoint: [""]              # the image's default entrypoint is "aws", which breaks script lines
  script:
    - set -eu
    - DIGEST=$(cat image-digest.txt)
    - |
      STATUS="NOT_STARTED"
      for i in $(seq 1 30); do
        if OUT=$(aws ecr describe-image-scan-findings \
              --repository-name "$ECR_REPO" \
              --image-id imageDigest="$DIGEST" \
              --query 'imageScanStatus.status' --output text 2&amp;gt;&amp;amp;1); then
          STATUS="$OUT"
        else
          case "$OUT" in
            *ScanNotFoundException*) STATUS="NOT_STARTED" ;;    # scan not registered yet: retry
            *) echo "$OUT"; echo "API error, failing closed"; exit 1 ;;
          esac
        fi
        echo "attempt $i: scan status = $STATUS"
        case "$STATUS" in
          COMPLETE|ACTIVE) break ;;                              # ACTIVE appears with enhanced scanning
          FAILED|UNSUPPORTED_IMAGE|SCAN_ELIGIBILITY_EXPIRED|FINDINGS_UNAVAILABLE)
            echo "scan unusable: $STATUS"; exit 1 ;;
        esac
        sleep 10
      done
      case "$STATUS" in COMPLETE|ACTIVE) ;; *) echo "scan never completed"; exit 1 ;; esac
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The loop ends in a failure unless a terminal good status was seen. Timing out is a failure, not a pass. Verify the exact status values your registry returns against the AWS documentation before relying on the &lt;code&gt;case&lt;/code&gt; list.&lt;/p&gt;

&lt;h2&gt;Fix #2: Gate on explicit severity counts with a default&lt;/h2&gt;

&lt;p&gt;Once the scan is complete, a missing severity key genuinely means zero findings. That default is safe only after Fix #1 has confirmed the status. Append this to the same script, and make the threshold a variable so teams can tune it. Anything other than a number or &lt;code&gt;None&lt;/code&gt;, including an empty result from a failed API call, stops the job.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;    - |
      count() {
        # prints a number; maps the CLI's "None" (key absent) to 0 -- safe ONLY after status is COMPLETE/ACTIVE
        V=$(aws ecr describe-image-scan-findings \
              --repository-name "$ECR_REPO" \
              --image-id imageDigest="$DIGEST" \
              --query "imageScanFindings.findingSeverityCounts.$1" --output text) || return 1
        case "$V" in
          None) echo 0 ;;
          ''|*[!0-9]*) echo "unexpected count for $1: '$V'" &amp;gt;&amp;amp;2; return 1 ;;
          *) echo "$V" ;;
        esac
      }
      CRIT=$(count CRITICAL) || { echo "could not read CRITICAL count"; exit 1; }
      HIGH=$(count HIGH)     || { echo "could not read HIGH count"; exit 1; }
      echo "CRITICAL=$CRIT HIGH=$HIGH (max allowed HIGH=${MAX_HIGH:-0})"
      [ "$CRIT" -eq 0 ] || { echo "blocked: critical findings"; exit 1; }
      [ "$HIGH" -le "${MAX_HIGH:-0}" ] || { echo "blocked: too many high findings"; exit 1; }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; the log must print the counts. When a reviewer later asks why a build passed, "CRITICAL=0 HIGH=0" in the job output is the evidence. Also note that a gate on severity counts alone cannot express an exception for a known, accepted CVE. Handle that with a reviewed allowlist file rather than by loosening the threshold.&lt;/p&gt;

&lt;h2&gt;Fix #3: Pin to the digest and handle scan triggers deliberately&lt;/h2&gt;

&lt;p&gt;Scan the digest that was pushed, and hand that same digest to the deploy job. This closes the mutable-tag gap. Capture the digest at push time and publish it as an artifact. The job image must provide both Docker (for example via a Docker-in-Docker service) and the AWS CLI.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;build-push:
  stage: build
  script:
    - set -eu
    - aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "${ECR_URI%%/*}"
    - docker build -t "$ECR_URI:$CI_COMMIT_SHA" .
    - docker push "$ECR_URI:$CI_COMMIT_SHA"
    - |
      aws ecr describe-images --repository-name "$ECR_REPO" \
        --image-ids imageTag="$CI_COMMIT_SHA" \
        --query 'imageDetails[0].imageDigest' --output text &amp;gt; image-digest.txt
    - echo "IMAGE_DIGEST=$(cat image-digest.txt)" &amp;gt; build.env
  artifacts:
    paths: [image-digest.txt]
    reports:
      dotenv: build.env     # deploy jobs read $IMAGE_DIGEST and deploy by digest, never by tag
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Next, decide who triggers the scan. With scan-on-push enabled on the repository, the gate only reads results. If you rely on a manual &lt;code&gt;aws ecr start-image-scan&lt;/code&gt; call instead, basic scanning limits how often one image can be rescanned, so a retried job can hit &lt;code&gt;LimitExceededException&lt;/code&gt;. Treat that specific error as "scan already requested, keep polling", and fail on every other error. With enhanced scanning, manual scans are not the mechanism at all, so check the AWS docs for your configuration.&lt;/p&gt;

&lt;p&gt;Finally, scope IAM per job. The gate job needs only &lt;code&gt;ecr:DescribeImageScanFindings&lt;/code&gt; on that repository. The build job needs &lt;code&gt;ecr:GetAuthorizationToken&lt;/code&gt; (which applies to all resources), the push actions (&lt;code&gt;ecr:BatchCheckLayerAvailability&lt;/code&gt;, &lt;code&gt;ecr:InitiateLayerUpload&lt;/code&gt;, &lt;code&gt;ecr:UploadLayerPart&lt;/code&gt;, &lt;code&gt;ecr:CompleteLayerUpload&lt;/code&gt;, &lt;code&gt;ecr:PutImage&lt;/code&gt;), and &lt;code&gt;ecr:DescribeImages&lt;/code&gt; to read the digest. Obtain these roles through GitLab's &lt;a href="https://docs.gitlab.com/ci/secrets/id_token_authentication/" rel="noopener noreferrer"&gt;ID token authentication&lt;/a&gt; rather than stored access keys. A missing permission otherwise surfaces as an &lt;code&gt;AccessDenied&lt;/code&gt; that an old &lt;code&gt;|| true&lt;/code&gt; would hide.&lt;/p&gt;

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

&lt;p&gt;Make the failure mode part of the pipeline design, not a one-time patch. Use this checklist when you add or review the gate.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[ ] Scan-on-push is enabled on the repository (or enhanced scanning is configured at registry level)
[ ] Gate queries by imageDigest, not by mutable tag
[ ] Gate waits for a terminal status; a timeout exits non-zero
[ ] FAILED / UNSUPPORTED_IMAGE / SCAN_ELIGIBILITY_EXPIRED / FINDINGS_UNAVAILABLE exit non-zero
[ ] Only ScanNotFoundException is retried; other API errors exit non-zero
[ ] Missing severity key is read as 0 ONLY after a completed status
[ ] Non-numeric or empty counts exit non-zero
[ ] No "|| true" and "set -eu" is active in the script
[ ] Counts are printed in the job log
[ ] Deploy job consumes the same digest via dotenv artifact
[ ] IAM roles are least-privilege per job and obtained via OIDC, not static keys
[ ] A known-vulnerable test image proves the gate actually fails
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The last item matters most. Push an old base image with known critical CVEs to a throwaway repository and confirm the job goes red. A gate that has never failed has never been shown to work.&lt;/p&gt;

&lt;p&gt;Also plan for drift. A clean image today can have new findings next week, and a one-time gate at build time will not see them. Enhanced scanning re-evaluates images continuously. You can combine it with a scheduled GitLab pipeline that re-runs the digest check against what is currently deployed. For more pipeline and AWS 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/10/13/step-functions-orchestration-for-lambda-and-s3-which-pattern/" rel="noopener noreferrer"&gt;Step Functions Orchestration for Lambda and S3: Which Pattern?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/08/ci-cd-quality-gate-mistakes-approvals-and-rollback-pitfalls/" rel="noopener noreferrer"&gt;CI/CD Quality Gate Mistakes: Approvals and Rollback Pitfalls&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/07/datasync-eventbridge-automation-checklist-for-s3-transfers/" rel="noopener noreferrer"&gt;DataSync EventBridge Automation Checklist for S3 Transfers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Content Workflow Automation Failing in n8n or Bedrock? Fixes</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:02:21 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/content-workflow-automation-failing-in-n8n-or-bedrock-fixes-3eoj</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/content-workflow-automation-failing-in-n8n-or-bedrock-fixes-3eoj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/12/content-workflow-automation-failing-in-n8n-or-bedrock-fixes" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A content workflow automation built in n8n passes every test with one topic, then fails the first time an editor submits twenty. Some drafts never arrive, a few arrive twice, and one is cut off mid-sentence and sits in WordPress as a "ready" post. This runbook covers the failure modes that show up when n8n orchestrates Amazon Bedrock calls, and the changes that remove them.&lt;/p&gt;

&lt;h2&gt;Symptoms&lt;/h2&gt;



&lt;p&gt;Several symptoms often appear together, but each points to a different cause.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ThrottlingException or HTTP 429&lt;/strong&gt; from Bedrock when a batch starts, often within the first few items depending on your quota and model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ValidationException&lt;/strong&gt; mentioning the model identifier or saying on-demand throughput is not supported for that model. The exact wording is version- and model-dependent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AccessDeniedException&lt;/strong&gt; on a model that works in the console, commonly because the IAM role, the region, or the account's model access differs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;504 or "Gateway Timeout"&lt;/strong&gt; returned to the caller of an n8n webhook while the execution keeps running in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate posts&lt;/strong&gt; after a retry, a restart, or a client that resent the webhook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broken or truncated output&lt;/strong&gt;: invalid JSON in a "parse" step, missing closing sections, or markdown fences inside the post body.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If only the first symptom is present, jump to Fix #1. If posts are duplicated, start with Fix #2.&lt;/p&gt;

&lt;h2&gt;Root cause&lt;/h2&gt;

&lt;p&gt;These failures usually come from treating an LLM call like an ordinary fast API call inside a content workflow automation. Three assumptions break under real load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assumption 1: capacity is unlimited.&lt;/strong&gt; Bedrock enforces per-model, per-region quotas on requests and tokens per minute. n8n's default behavior is to push items through a node as fast as it can, so a batch of items becomes a burst. The documented behavior is a throttling error, and quotas differ by model and account. Verify yours in the Bedrock quotas documentation and the Service Quotas console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assumption 2: the call finishes before the caller gives up.&lt;/strong&gt; Long generations can take tens of seconds, depending on the model and output length. A webhook that waits for the whole chain hits the proxy or load balancer timeout first, the client retries, and the workflow runs again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assumption 3: the output is trustworthy.&lt;/strong&gt; A model can stop at the token limit, wrap JSON in prose, or return a different structure than requested. If the next node publishes whatever arrives, the defect lands in production.&lt;/p&gt;

&lt;p&gt;Watch out for the interaction between 2 and 3: a retry after a timeout is non-idempotent by default, so each retry produces a fresh, slightly different article.&lt;/p&gt;

&lt;h2&gt;Fix #1: Control concurrency and retry throttling&lt;/h2&gt;

&lt;p&gt;Slow the burst down and let retries absorb the remainder. In n8n, put the Bedrock call inside a Loop Over Items (Split In Batches) node with a batch size of 1 to 3, and add a Wait node when quotas are tight. On the node that makes the Bedrock request (for example HTTP Request with AWS authentication), enable &lt;em&gt;Retry On Fail&lt;/em&gt; with a wait between tries of several seconds. n8n caps the number of tries, so treat this as a complement to batching, not a replacement.&lt;/p&gt;

&lt;p&gt;If the call runs in a Lambda worker instead of an n8n node, let the AWS SDK for JavaScript v3 do the backoff, and set the function timeout well above the expected generation time plus retries. The sketch below uses the Converse API, which gives one request shape across supported models.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";

const client = new BedrockRuntimeClient({
  region: process.env.AWS_REGION,
  maxAttempts: 8,         // SDK retries throttling errors with backoff
  retryMode: "adaptive",  // adds client-side rate limiting on throttles
});

export const handler = async (event) =&amp;gt; {
  const res = await client.send(new ConverseCommand({
    modelId: process.env.MODEL_ID,   // inference profile ID/ARN if the model requires one
    messages: [{ role: "user", content: [{ text: event.prompt }] }],
    inferenceConfig: { maxTokens: 2000, temperature: 0.3 },
  }));
  return {
    text: res.output.message.content[0].text,
    stopReason: res.stopReason,      // "max_tokens" means the draft was cut off
  };
};&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Two gotchas here. Some newer models are only invokable through a cross-region inference profile, so the plain model ID triggers the ValidationException above; check the model's page in the Bedrock documentation. Also, the IAM policy must allow &lt;code&gt;bedrock:InvokeModel&lt;/code&gt; on the profile and on the underlying models in each region it routes to. If throttling persists at low volume, request a quota increase rather than adding more retries.&lt;/p&gt;

&lt;h2&gt;Fix #2: Acknowledge fast and make runs idempotent&lt;/h2&gt;

&lt;p&gt;Stop the webhook from waiting on the generation. Set the Webhook node's response mode to respond immediately, return a job ID, and let the rest of the workflow run asynchronously. For sustained volume, run n8n in queue mode so workers pull executions from Redis and a concurrency cap protects Bedrock.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# docker-compose.yml excerpt (no top-level "version" key needed)
services:
  n8n-worker:
    image: docker.n8n.io/n8nio/n8n:latest   # pin an exact tag in real deployments
    command: worker --concurrency=3         # parallel executions per worker
    environment:
      - EXECUTIONS_MODE=queue
      - QUEUE_BULL_REDIS_HOST=redis
      - EXECUTIONS_TIMEOUT=600              # kill runaway executions (seconds)
      - NODE_FUNCTION_ALLOW_BUILTIN=crypto  # lets Code nodes hash input for dedupe&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Variable names and worker flags change between n8n releases, and workers also need the same database and encryption key settings as the main instance. Confirm everything in the &lt;a href="https://docs.n8n.io/hosting/configuration/environment-variables/" rel="noopener noreferrer"&gt;n8n environment variable reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Then add an idempotency key. Hash the topic, target keyword, and requester into a stable ID, and claim it atomically before generating anything, for example by inserting it into a Postgres table with a unique constraint. If the insert fails because the key exists, exit the run. A separate lookup followed by a write lets two concurrent duplicates both pass. &lt;strong&gt;Do not use the execution ID as the key&lt;/strong&gt;: a resent webhook or a restart starts a new execution with a new ID, which defeats the check.&lt;/p&gt;

&lt;h2&gt;Fix #3: Validate output before anything publishes&lt;/h2&gt;

&lt;p&gt;Add a gate between generation and publishing. Check the stop reason, parse the structure, and enforce basic rules. Anything that fails goes to a review queue, not to the CMS.&lt;/p&gt;

&lt;p&gt;This n8n Code node sketch assumes the previous step returns &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;stopReason&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// n8n Code node, "Run Once for Each Item"
const { text, stopReason } = $json;
const errors = [];

// plain-text generation should end with "end_turn"; tool-use flows return other reasons
if (stopReason !== "end_turn") errors.push(`unexpected stop: ${stopReason}`);

let post;
try {
  // strip accidental markdown fences before parsing
  post = JSON.parse(text.replace(/^

```(?:json)?\s*|\s*```

$/g, ""));
} catch (e) {
  errors.push("invalid JSON");
}

if (post) {
  if (!post.title || post.title.length &amp;gt; 70) errors.push("bad title");
  if (!post.body || post.body.split(/\s+/).length &amp;lt; 600) errors.push("body too short");
  if (post.body &amp;amp;&amp;amp; /lorem ipsum|as an ai/i.test(post.body)) errors.push("placeholder text");
}

return { json: { ok: errors.length === 0, errors, post } };&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Route on &lt;code&gt;ok&lt;/code&gt; with an IF node. Passing items should be created as &lt;strong&gt;drafts&lt;/strong&gt; via the WordPress node, never straight to published. Where the model supports it, tool use or structured output in the Converse request can reduce parse failures further, though support varies by model. Verify with the Bedrock documentation.&lt;/p&gt;

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

&lt;p&gt;Build these guardrails in once so later batches do not depend on manual checks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a CloudWatch alarm on Bedrock's &lt;code&gt;InvocationThrottles&lt;/code&gt; and client-error metrics, so throttling shows up before editors report it.&lt;/li&gt;
&lt;li&gt;Attach an n8n Error Trigger workflow that posts failed execution IDs and node names to a chat channel.&lt;/li&gt;
&lt;li&gt;Keep model IDs and inference profiles in configuration, not hard-coded in nodes, so a deprecation is a one-line change.&lt;/li&gt;
&lt;li&gt;Export workflows as JSON to Git, and review changes like code.&lt;/li&gt;
&lt;li&gt;Set an AWS Budgets alert on Bedrock spend. A looping retry bug is also a billing bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pre-launch checklist for every new workflow:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[ ] Batch size and Wait set; tested with 20+ items, not 1
[ ] Retry On Fail enabled; SDK maxAttempts/backoff configured
[ ] Webhook responds immediately; job ID returned
[ ] Idempotency key claimed atomically before generation
[ ] stopReason and schema validated before publish
[ ] Output lands as draft; human approval required
[ ] IAM scoped to the specific model/profile ARNs
[ ] Alarms: throttles, errors, spend
[ ] Failure test: kill a worker mid-run, resend webhook, confirm no duplicates&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The last item matters most. Duplicate and half-finished runs only appear when something fails on purpose. For more runbooks on automation and cloud tooling, browse &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;, and check the &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/quotas.html" rel="noopener noreferrer"&gt;Amazon Bedrock quotas documentation&lt;/a&gt; whenever you add a model or region.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/09/bash-backup-script-not-running-from-cron-s3-sync-fixes/" rel="noopener noreferrer"&gt;Bash backup script not running from cron: S3 sync fixes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/05/bedrock-iam-least-privilege-stopping-runaway-bot-costs/" rel="noopener noreferrer"&gt;Bedrock IAM Least Privilege: Stopping Runaway Bot Costs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/10/08/ci-cd-quality-gate-mistakes-approvals-and-rollback-pitfalls/" rel="noopener noreferrer"&gt;CI/CD Quality Gate Mistakes: Approvals and Rollback Pitfalls&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Kubernetes Ingress Manifest or Gateway API: Which to Ship</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sun, 11 Oct 2026 07:01:20 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/kubernetes-ingress-manifest-or-gateway-api-which-to-ship-1b2k</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/kubernetes-ingress-manifest-or-gateway-api-which-to-ship-1b2k</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/11/kubernetes-ingress-manifest-or-gateway-api-which-to-ship" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A team has a working Deployment and a ClusterIP Service, and now needs a public hostname with TLS. The familiar route is a Kubernetes Ingress manifest, but the ecosystem has been moving toward Gateway API, and the most widely copied Ingress controller has been announced for retirement. The Deployment and Service stay almost identical either way. What changes is the object that exposes them, and that choice is worth making deliberately.&lt;/p&gt;

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



&lt;p&gt;For a single app on a single cluster with one hostname, both approaches work, and the difference is mostly about future maintenance. The choice starts to matter when several teams share a cluster, when you need weighted traffic splits or header-based routing, or when your current controller is going out of support.&lt;/p&gt;

&lt;p&gt;The ingress-nginx project announced its retirement, with best-effort maintenance planned to end in March 2026. Verify the current status in the project repository before building on it. A manifest full of controller-specific annotations is the part that hurts most during a migration, because those annotations are not portable.&lt;/p&gt;

&lt;p&gt;Whichever you pick, the first two objects are the same. Here is the shared base: a Deployment and a Service using stable APIs.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web              # must match the pod template labels exactly
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: registry.example.com/web:1.4.2   # pin a tag or digest, not :latest
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:    # gates Service endpoints; without it traffic hits cold pods
            httpGet:
              path: /healthz
              port: http
          resources:
            requests: { cpu: 100m, memory: 128Mi }
            limits: { memory: 256Mi }
---
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: ClusterIP
  selector:
    app: web                # a typo here yields a Service with zero endpoints
  ports:
    - name: http
      port: 80
      targetPort: http      # refers to the named container port above&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; a Service selector that does not match the pod labels fails silently. The Service exists, DNS resolves, and every request errors. Verify with &lt;code&gt;kubectl get endpointslices -l kubernetes.io/service-name=web&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Option A: A classic Kubernetes Ingress manifest&lt;/h2&gt;

&lt;p&gt;Ingress (&lt;code&gt;networking.k8s.io/v1&lt;/code&gt;) is the established path. One object maps a host and path to a Service, and a controller you install separately implements it. The documented behavior is that the API is stable but frozen; the Kubernetes docs point new feature work to Gateway API.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
spec:
  ingressClassName: public      # replaces the old kubernetes.io/ingress.class annotation
  tls:
    - hosts: [app.example.com]
      secretName: app-example-tls   # kubernetes.io/tls Secret in the same namespace
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix    # required field in v1
            backend:
              service:
                name: web
                port:
                  name: http&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;Small and widely understood. Most tutorials, Helm charts, and cert-manager examples assume it.&lt;/li&gt;
&lt;li&gt;One object per app is easy to review in a pull request.&lt;/li&gt;
&lt;li&gt;Mature support across managed cloud load balancer controllers and self-hosted proxies.&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 spec covers only host and path routing. Everything else, such as rewrites, timeouts, and canaries, lives in controller-specific annotations that do not carry over to another controller.&lt;/li&gt;
&lt;li&gt;Roles are blurred. The app team that writes the Ingress also effectively touches shared edge behavior.&lt;/li&gt;
&lt;li&gt;No new features are planned for the API itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; an Ingress with a missing or wrong &lt;code&gt;ingressClassName&lt;/code&gt; is simply ignored by every controller. It looks valid, &lt;code&gt;kubectl apply&lt;/code&gt; succeeds, and nothing is routed.&lt;/p&gt;

&lt;h2&gt;Option B: Gateway API with HTTPRoute&lt;/h2&gt;

&lt;p&gt;Gateway API (&lt;code&gt;gateway.networking.k8s.io/v1&lt;/code&gt;) splits the problem into objects owned by different roles. A platform team defines a GatewayClass and a Gateway (listeners, ports, TLS); app teams attach HTTPRoutes to it. The Deployment and Service above do not change.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web
spec:
  parentRefs:
    - name: shared-gateway       # Gateway owned by the platform team; its allowedRoutes must permit this namespace
      namespace: infra
  hostnames: [app.example.com]
  rules:
    - matches:
        - path: { type: PathPrefix, value: / }
      backendRefs:
        - name: web
          port: 80
          weight: 100            # add a second backendRef to split traffic natively&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;Header matching, redirects, request header modification, and weighted backends are part of the standard API, not annotations.&lt;/li&gt;
&lt;li&gt;Role separation: the Gateway can restrict which namespaces may attach routes.&lt;/li&gt;
&lt;li&gt;Portable across conformant implementations, though implementation-specific extensions still exist.&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 CRDs are not built into Kubernetes. You install them, pick a channel (standard or experimental), and must keep versions compatible with your controller.&lt;/li&gt;
&lt;li&gt;More objects to understand, and cross-namespace references need explicit grants such as ReferenceGrant.&lt;/li&gt;
&lt;li&gt;Implementation coverage varies. Check your controller's conformance report for the features you need.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; an HTTPRoute can be created successfully yet not be attached to the Gateway, for example when the Gateway's &lt;code&gt;allowedRoutes&lt;/code&gt; excludes your namespace or a backend reference does not resolve. Always read &lt;code&gt;status.parents[].conditions&lt;/code&gt; on the HTTPRoute for &lt;code&gt;Accepted&lt;/code&gt; and &lt;code&gt;ResolvedRefs&lt;/code&gt; instead of assuming apply means working.&lt;/p&gt;

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

&lt;p&gt;The table compares the two approaches on the factors that usually decide the question. It describes classes of approach, not specific products.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Ingress&lt;/th&gt;
&lt;th&gt;Gateway API&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One app, one host, simple TLS&lt;/td&gt;
&lt;td&gt;Fits well&lt;/td&gt;
&lt;td&gt;Works, more setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple teams on shared edge&lt;/td&gt;
&lt;td&gt;Weak isolation&lt;/td&gt;
&lt;td&gt;Designed for it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canary or header routing&lt;/td&gt;
&lt;td&gt;Annotations, non-portable&lt;/td&gt;
&lt;td&gt;Standard fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cluster add-ons required&lt;/td&gt;
&lt;td&gt;Controller only&lt;/td&gt;
&lt;td&gt;Controller plus CRDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock-in on migration&lt;/td&gt;
&lt;td&gt;High if annotation-heavy&lt;/td&gt;
&lt;td&gt;Lower, extensions aside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-term API direction&lt;/td&gt;
&lt;td&gt;Frozen&lt;/td&gt;
&lt;td&gt;Actively developed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A quick checklist to run against your own cluster before deciding:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# 1. What implements edge traffic today? (gatewayclass errors if the CRDs are absent)
kubectl get ingressclass,gatewayclass

# 2. How many lines of controller-specific annotations are you carrying?
kubectl get ingress -A -o yaml | grep -c 'nginx.ingress.kubernetes.io'

# 3. Are Gateway API CRDs installed, and which version?
kubectl get crd gateways.gateway.networking.k8s.io \
  -o jsonpath='{.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version}'&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A high count in step 2 is a rough indicator of how much annotation-specific work a migration will involve, since each one needs an equivalent or a deliberate replacement.&lt;/p&gt;

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

&lt;p&gt;For new clusters, start with Gateway API if your chosen controller documents conformance for the features you need. The API is where development is happening, and it keeps routing logic out of annotations. Platform teams serving several app teams get the most benefit, because the Gateway/HTTPRoute split matches how responsibility already divides.&lt;/p&gt;

&lt;p&gt;Keep a plain Kubernetes Ingress manifest when the cluster is small, the routing is host and path only, and your controller's Gateway API support is immature or unverified. It remains valid and supported as an API. Just keep annotations to a minimum and write down each one, so a later move is a mechanical task. The Gateway API project provides an &lt;code&gt;ingress2gateway&lt;/code&gt; tool for converting existing resources; verify its coverage of your controller and annotations before relying on it.&lt;/p&gt;

&lt;p&gt;If you already run an Ingress controller that is retiring or unmaintained, treat that as the deciding factor. Plan the move to a maintained controller, and use the moment to adopt Gateway API instead of swapping one annotation dialect for another. Migrate one low-risk hostname first, compare behavior, then shift the rest.&lt;/p&gt;

&lt;p&gt;Whatever you choose, keep the Deployment and Service boring: pinned image, readiness probe, named ports, matching selectors. Many "my Ingress is broken" problems trace back to those, not the edge object. For the authoritative field reference, see the &lt;a href="https://kubernetes.io/docs/concepts/services-networking/ingress/" rel="noopener noreferrer"&gt;Kubernetes Ingress documentation&lt;/a&gt; and the &lt;a href="https://kubernetes.io/docs/concepts/services-networking/gateway/" rel="noopener noreferrer"&gt;Gateway API overview&lt;/a&gt;. More practical Kubernetes and DevOps 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/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;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/11/kubernetes-probe-misconfigurations-behind-restart-storms/" rel="noopener noreferrer"&gt;Kubernetes Probe Misconfigurations Behind Restart Storms&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
    <item>
      <title>Terraform Modules for AWS VPC, IAM, and S3: Structure That Scales</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sat, 10 Oct 2026 07:02:15 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/terraform-modules-for-aws-vpc-iam-and-s3-structure-that-scales-465m</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/terraform-modules-for-aws-vpc-iam-and-s3-structure-that-scales-465m</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/10/terraform-modules-for-aws-vpc-iam-and-s3-structure-that-scales" 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 second team asks for "the same network and storage setup as the first one," and suddenly your single 600-line &lt;code&gt;main.tf&lt;/code&gt; needs to become Terraform modules that two environments can share. The VPC, the application bucket, and the role that reads from it are all tangled together, and copying the file means copying every hardcoded CIDR and bucket name with it.&lt;/p&gt;

&lt;p&gt;This guide splits that setup into three small modules: &lt;strong&gt;vpc&lt;/strong&gt;, &lt;strong&gt;s3&lt;/strong&gt;, and &lt;strong&gt;iam&lt;/strong&gt;. A thin root configuration then composes them per environment. The design rule is simple. Each module owns one concern, exposes a few typed inputs, and returns only the outputs that other modules need.&lt;/p&gt;

&lt;p&gt;The IAM module does not create or look up the bucket. It receives a bucket ARN as an input, which keeps the dependency visible and one-directional. The VPC module stands alone. Nothing here is exotic, but the boundaries are where most module designs go wrong, and they are what make reuse possible later.&lt;/p&gt;

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

&lt;p&gt;You need Terraform 1.6 or newer (or a compatible OpenTofu release) and the AWS provider on the current 6.x line. Pin both, and check the provider changelog before bumping a major version. You also need an AWS account you can safely experiment in, with credentials supplied through SSO, an assumed role, or environment variables rather than static keys in code.&lt;/p&gt;

&lt;p&gt;Use this layout. The &lt;code&gt;modules/&lt;/code&gt; directory holds reusable code, and &lt;code&gt;envs/&lt;/code&gt; holds one small root configuration per environment:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;infra/
  modules/
    vpc/   (main.tf, variables.tf, outputs.tf)
    s3/    (main.tf, variables.tf, outputs.tf)
    iam/   (main.tf, variables.tf, outputs.tf)
  envs/
    dev/   (main.tf, providers.tf, backend.tf)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; do not put &lt;code&gt;provider&lt;/code&gt; blocks inside child modules. Modules inherit the provider from the caller, and embedded provider configuration makes removal and reuse painful. The HashiCorp guidance on &lt;a href="https://developer.hashicorp.com/terraform/language/modules/develop" rel="noopener noreferrer"&gt;module development&lt;/a&gt; covers this and the standard file structure.&lt;/p&gt;

&lt;p&gt;Also decide on remote state now. An S3 backend with locking is common. Recent Terraform releases support native S3 lock files via &lt;code&gt;use_lockfile = true&lt;/code&gt;, and DynamoDB-based locking is deprecated. The backend block belongs in the root configuration, never in a module.&lt;/p&gt;

&lt;h2&gt;Step 1: Build the VPC module&lt;/h2&gt;



&lt;p&gt;Keep the first Terraform modules boring. This VPC module takes a name, a CIDR, and a map of availability zones to fixed subnet indexes, then derives subnets with &lt;code&gt;cidrsubnet&lt;/code&gt;. It deliberately omits NAT gateways, which cost money per hour and per GB, so you can add them behind an explicit flag later.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# modules/vpc/main.tf
variable "name" { type = string }

variable "cidr_block" {
  type    = string
  default = "10.0.0.0/16"
}

# AZ name =&amp;gt; fixed subnet index, e.g. { "eu-central-1a" = 0 }
variable "azs" { type = map(number) }

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags                 = { Name = var.name }
}

# keyed by AZ name with a fixed index: adding or removing a zone
# does not renumber or recreate the others
resource "aws_subnet" "private" {
  for_each          = var.azs
  vpc_id            = aws_vpc.this.id
  availability_zone = each.key
  cidr_block        = cidrsubnet(var.cidr_block, 8, each.value)
  tags              = { Name = "${var.name}-private-${each.key}" }
}

resource "aws_subnet" "public" {
  for_each                = var.azs
  vpc_id                  = aws_vpc.this.id
  availability_zone       = each.key
  cidr_block              = cidrsubnet(var.cidr_block, 8, each.value + 100) # offset avoids overlap
  map_public_ip_on_launch = false
  tags                    = { Name = "${var.name}-public-${each.key}" }
}

output "vpc_id"             { value = aws_vpc.this.id }
output "private_subnet_ids" { value = [for s in aws_subnet.private : s.id] }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; using &lt;code&gt;count&lt;/code&gt;, or &lt;code&gt;for_each&lt;/code&gt; with an index derived from list position, ties each subnet's CIDR to its position in the list. Removing the first zone shifts the indexes, changes the computed CIDRs, and proposes replacing real subnets. Keying &lt;code&gt;for_each&lt;/code&gt; on the AZ name and giving each zone a fixed index avoids that. Add an internet gateway and route tables when you actually need public routing.&lt;/p&gt;

&lt;h2&gt;Step 2: Build the S3 module with safe defaults&lt;/h2&gt;

&lt;p&gt;Since the AWS provider v4 split, bucket settings live in separate resources rather than inline arguments. The module should make the secure choice the default: block public access, enable versioning, and set encryption explicitly even though S3 now encrypts new objects with SSE-S3 by default. Explicit config documents intent and lets you switch to KMS.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# modules/s3/main.tf
variable "bucket_name" { type = string }

variable "force_destroy" {
  type    = bool
  default = false
}

variable "kms_key_arn" {
  description = "KMS key ARN; null means SSE-S3"
  type        = string
  default     = null
}

resource "aws_s3_bucket" "this" {
  bucket        = var.bucket_name
  force_destroy = var.force_destroy
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket                  = aws_s3_bucket.this.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

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

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  bucket = aws_s3_bucket.this.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = var.kms_key_arn == null ? "AES256" : "aws:kms"
      kms_master_key_id = var.kms_key_arn
    }
  }
}

output "bucket_arn" { value = aws_s3_bucket.this.arn }
output "bucket_id"  { value = aws_s3_bucket.this.id }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; bucket names are global across all AWS accounts, so pass a name that includes account or environment context. Leave &lt;code&gt;force_destroy&lt;/code&gt; false by default so a careless &lt;code&gt;destroy&lt;/code&gt; cannot wipe data. See the AWS documentation on &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html" rel="noopener noreferrer"&gt;Block Public Access&lt;/a&gt; for what each of the four flags does.&lt;/p&gt;

&lt;h2&gt;Step 3: Build the IAM module around least privilege&lt;/h2&gt;

&lt;p&gt;The IAM module creates a role that EC2 can assume and attaches a policy scoped to one bucket. It takes the bucket ARN as input rather than reaching into the S3 module, which keeps it reusable for any resource that exposes an ARN. Policy documents built with the data source are easier to review than JSON heredocs.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# modules/iam/main.tf
variable "name"       { type = string }
variable "bucket_arn" { type = string }

data "aws_iam_policy_document" "assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "bucket_read" {
  statement {
    actions   = ["s3:ListBucket"]
    resources = [var.bucket_arn]          # bucket-level action
  }
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${var.bucket_arn}/*"]   # object-level action needs /*
  }
}

resource "aws_iam_role" "this" {
  name               = var.name
  assume_role_policy = data.aws_iam_policy_document.assume.json
}

resource "aws_iam_role_policy" "bucket_read" {
  name   = "${var.name}-bucket-read"
  role   = aws_iam_role.this.id
  policy = data.aws_iam_policy_document.bucket_read.json
}

output "role_arn" { value = aws_iam_role.this.arn }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A typical failure here is granting &lt;code&gt;s3:GetObject&lt;/code&gt; on the bucket ARN alone. The call is denied because object actions require the &lt;code&gt;/*&lt;/code&gt; resource form, while &lt;code&gt;ListBucket&lt;/code&gt; requires the bare bucket ARN. Both statements above are needed.&lt;/p&gt;

&lt;h2&gt;Step 4: Compose the modules in a root configuration&lt;/h2&gt;

&lt;p&gt;The root configuration is where environment decisions live. It passes values in and wires outputs to inputs, and Terraform infers the order from those references.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# envs/dev/main.tf
terraform {
  required_version = "&amp;gt;= 1.6"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~&amp;gt; 6.0" }
  }
}

provider "aws" { region = "eu-central-1" }

module "vpc" {
  source = "../../modules/vpc"
  name   = "dev"
  azs    = { "eu-central-1a" = 0, "eu-central-1b" = 1 }
}

module "data_bucket" {
  source      = "../../modules/s3"
  bucket_name = "example-dev-data-123456789012" # must be globally unique
}

module "app_role" {
  source     = "../../modules/iam"
  name       = "dev-app"
  bucket_arn = module.data_bucket.bucket_arn
}

output "app_role_arn" {
  value = module.app_role.role_arn
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Local paths are fine while one repository owns everything. When several teams consume the modules, publish them to a registry or a Git tag and pin a version, so a change to &lt;code&gt;modules/s3&lt;/code&gt; cannot silently alter another team's plan.&lt;/p&gt;

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

&lt;p&gt;Run static checks first, then read the plan critically before any apply. In a sandbox account, apply once and confirm the results with the AWS CLI rather than trusting the plan alone.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;terraform fmt -check -recursive      # formatting gate for CI
terraform init
terraform validate                   # catches wrong types and missing arguments
terraform plan -out=tfplan           # review: expect only creates on a first run
terraform apply tfplan

# confirm the real bucket settings
aws s3api get-public-access-block --bucket example-dev-data-123456789012
aws s3api get-bucket-versioning   --bucket example-dev-data-123456789012

# confirm the role can read but not write
aws iam simulate-principal-policy \
  --policy-source-arn "$(terraform output -raw app_role_arn)" \
  --action-names s3:GetObject s3:PutObject \
  --resource-arns "arn:aws:s3:::example-dev-data-123456789012/test.txt"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;app_role_arn&lt;/code&gt; output defined in the root configuration supplies the role ARN for this check. The simulation should report &lt;code&gt;s3:GetObject&lt;/code&gt; as allowed and &lt;code&gt;s3:PutObject&lt;/code&gt; as implicitly denied. Verify the exact response fields against the current AWS CLI reference.&lt;/p&gt;

&lt;p&gt;For regression checks, Terraform's native &lt;code&gt;terraform test&lt;/code&gt; command can run plan-level assertions against each module. Check the HashiCorp documentation for the syntax in your installed version. Finish with &lt;code&gt;terraform destroy&lt;/code&gt; in the sandbox so nothing lingers.&lt;/p&gt;

&lt;h2&gt;Where to go from here&lt;/h2&gt;

&lt;p&gt;Three small modules with typed inputs, secure defaults, and explicit wiring in the root are enough to stamp out new environments without copying files. Next, add NAT and routing behind a flag, an S3 bucket policy that enforces TLS, and a CI job that runs the checks above on every pull request. For more infrastructure patterns, browse the rest of &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;, and keep the module interfaces small, because every extra variable is something you have to support later.&lt;/p&gt;

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

&lt;ul&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;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/14/terraform-drift-detection-in-ci-building-a-remediation-pipeline/" rel="noopener noreferrer"&gt;Terraform Drift Detection in CI: Building a Remediation Pipeline&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/08/24/terraform-workspaces-vs-separate-backends-for-multi-env-aws/" rel="noopener noreferrer"&gt;Terraform Workspaces vs Separate Backends for Multi-Env AWS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Bash backup script not running from cron: S3 sync fixes</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Fri, 09 Oct 2026 07:02:21 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/bash-backup-script-not-running-from-cron-s3-sync-fixes-3g3g</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/bash-backup-script-not-running-from-cron-s3-sync-fixes-3g3g</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/09/bash-backup-script-not-running-from-cron-s3-sync-fixes" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;You wrote a bash backup script, ran it by hand, and watched the files land in S3. A week later someone asks for a restore and the newest object in the bucket is nine days old. This is a typical failure: the bash backup script works in your terminal and quietly does nothing, or something different, when cron runs it. This runbook walks from symptom to cause to three fixes.&lt;/p&gt;

&lt;h2&gt;Symptoms&lt;/h2&gt;



&lt;p&gt;Match what you see against this list before changing anything.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The script succeeds interactively, but the S3 bucket has no new objects after the scheduled time.&lt;/li&gt;
&lt;li&gt;The cron log shows the job started, yet no backup log is written. Where to look depends on the host:
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;grep CRON /var/log/syslog&lt;/code&gt; on Debian or Ubuntu with rsyslog.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/var/log/cron&lt;/code&gt; on RHEL-family systems.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;journalctl -u cron&lt;/code&gt; or &lt;code&gt;journalctl -u crond&lt;/code&gt; where only the journal exists.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Errors such as &lt;code&gt;aws: command not found&lt;/code&gt;, &lt;code&gt;Unable to locate credentials&lt;/code&gt;, or &lt;code&gt;The config profile (...) could not be found&lt;/code&gt; appear only in local mail or not at all.&lt;/li&gt;

&lt;li&gt;The backup log grows without limit, or the disk fills with archives in &lt;code&gt;/var/backups&lt;/code&gt;.&lt;/li&gt;

&lt;li&gt;Two backup processes show up in &lt;code&gt;ps&lt;/code&gt; at once, and the second one is still running when the next schedule fires.&lt;/li&gt;

&lt;li&gt;The log says "done" but the S3 prefix is missing files, or files deleted at the source are still in the bucket.&lt;/li&gt;

&lt;/ul&gt;


&lt;p&gt;If the cron daemon never logged the job at all, the problem is the crontab itself. Common causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The job is in the wrong user's crontab.&lt;/li&gt;
&lt;li&gt;The schedule line has a syntax error.&lt;/li&gt;
&lt;li&gt;A file in &lt;code&gt;/etc/cron.d&lt;/code&gt; has a dot in its name, is writable by group or other, or lacks a trailing newline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check that first with &lt;code&gt;crontab -l&lt;/code&gt; as the intended user, or inspect the &lt;code&gt;/etc/cron.d&lt;/code&gt; file.&lt;/p&gt;

&lt;h2&gt;Root cause: cron is not your shell&lt;/h2&gt;

&lt;p&gt;Cron runs jobs with a minimal environment. The common default on Linux cron implementations is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a short &lt;code&gt;PATH&lt;/code&gt; (often &lt;code&gt;/usr/bin:/bin&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SHELL=/bin/sh&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;no TTY;&lt;/li&gt;
&lt;li&gt;none of the exports from your &lt;code&gt;~/.bashrc&lt;/code&gt; or profile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything your interactive session provided silently is missing. That includes a pip-installed or &lt;code&gt;/usr/local/bin&lt;/code&gt; AWS CLI, &lt;code&gt;AWS_PROFILE&lt;/code&gt;, SSO sessions, relative paths, and even the working directory.&lt;/p&gt;

&lt;p&gt;Three further causes sit on top of that, and they explain the "ran but wrong" symptoms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Swallowed exit codes.&lt;/strong&gt; Without &lt;code&gt;set -euo pipefail&lt;/code&gt;, a failed &lt;code&gt;tar&lt;/code&gt; in a pipeline still lets the script continue and print "backup complete".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No overlap protection.&lt;/strong&gt; A slow upload runs into the next schedule, and two jobs fight over the same archive or the same log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Misread sync semantics.&lt;/strong&gt; &lt;code&gt;aws s3 sync&lt;/code&gt; compares size and modification time. It does not delete destination objects unless you pass &lt;code&gt;--delete&lt;/code&gt;. It also returns exit code 2 when some files were skipped (for example, unreadable files), which a naive script treats as success or ignores.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reproduce the cron environment before debugging anything else:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;env -i HOME="$HOME" PATH=/usr/bin:/bin /bin/sh -c '/opt/backup/backup.sh'
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Run it as the cron user. If it fails there, you have found the class of bug.&lt;/p&gt;

&lt;h2&gt;Fix #1: Give the bash backup script an explicit environment&lt;/h2&gt;

&lt;p&gt;Stop depending on anything cron does not provide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set &lt;code&gt;PATH&lt;/code&gt; at the top of the script and use absolute paths.&lt;/li&gt;
&lt;li&gt;Prefer an instance profile or IAM role over static keys.&lt;/li&gt;
&lt;li&gt;If you must use a named profile, set &lt;code&gt;AWS_PROFILE&lt;/code&gt;, and set &lt;code&gt;AWS_CONFIG_FILE&lt;/code&gt; and &lt;code&gt;AWS_SHARED_CREDENTIALS_FILE&lt;/code&gt; if the files are not in the cron user's home directory.&lt;/li&gt;
&lt;li&gt;Do not rely on IAM Identity Center (SSO) sessions for unattended jobs. They expire and cannot be refreshed without a browser login.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;See the AWS CLI credential options in the &lt;a href="https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-authentication.html" rel="noopener noreferrer"&gt;official authentication documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Crontab entry with the environment declared and output captured:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# /etc/cron.d/backup  (system crontabs need a user field)
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=ops@example.com

# 02:15 daily; note the escaped percent signs if you use date formats here
15 2 * * * backup /usr/bin/flock -n /var/lock/backup.lock /opt/backup/backup.sh &amp;gt;&amp;gt; /var/log/backup/backup.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for the percent sign.&lt;/strong&gt; In a crontab, an unescaped &lt;code&gt;%&lt;/code&gt; is treated as a newline, so &lt;code&gt;$(date +%F)&lt;/code&gt; directly in the cron line truncates the command. Put date logic inside the script, or write &lt;code&gt;\%&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also avoid &lt;code&gt;cd&lt;/code&gt;-less relative paths. A script that does &lt;code&gt;tar czf backup.tgz data/&lt;/code&gt; resolves paths against cron's working directory. That is typically the cron user's home directory, but it is implementation-dependent, so &lt;code&gt;cd&lt;/code&gt; explicitly or use absolute paths.&lt;/p&gt;

&lt;h2&gt;Fix #2: Fail loudly, lock, and clean up&lt;/h2&gt;

&lt;p&gt;Make every failure visible and stop overlapping runs. The &lt;code&gt;flock -n&lt;/code&gt; wrapper in the cron line skips a run if the previous one still holds the lock. Inside the script, strict mode and a &lt;code&gt;trap&lt;/code&gt; make failures non-silent and remove temporary archives.&lt;/p&gt;

&lt;p&gt;A minimal skeleton that propagates errors and records the exit state:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/usr/bin/env bash
set -Eeuo pipefail                      # exit on error, unset vars, pipe failures
export PATH=/usr/local/bin:/usr/bin:/bin

SRC=/srv/app/data
BUCKET=s3://example-backups/app         # replace with your bucket/prefix
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
TMP=$(mktemp -d /var/tmp/backup.XXXXXX)

log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*"; }
cleanup() { rm -rf "$TMP"; }
trap cleanup EXIT                       # always remove the temp dir
trap 'log "FAILED at line $LINENO"' ERR

log "start $STAMP"
tar -C "$SRC" -czf "$TMP/app-$STAMP.tgz" .
aws s3 cp "$TMP/app-$STAMP.tgz" "$BUCKET/archives/app-$STAMP.tgz" --only-show-errors
log "done"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Watch out for &lt;code&gt;set -e&lt;/code&gt; inside conditionals and &lt;code&gt;||&lt;/code&gt; or &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; chains. It is deliberately disabled there, so a failure inside an &lt;code&gt;if&lt;/code&gt; test is not fatal. That also applies inside functions called from those contexts. Check critical commands explicitly when they sit there.&lt;/p&gt;

&lt;p&gt;Uploading a single archive with &lt;code&gt;aws s3 cp&lt;/code&gt; gives you one clear exit status. That is easier to alert on than a directory sync when the goal is a point-in-time backup.&lt;/p&gt;

&lt;h2&gt;Fix #3: Correct S3 sync behavior and rotate the logs&lt;/h2&gt;

&lt;p&gt;If you do mirror a directory, decide deliberately what deletions mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Without &lt;code&gt;--delete&lt;/code&gt;, &lt;code&gt;aws s3 sync&lt;/code&gt; keeps removed files in the bucket forever.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;--delete&lt;/code&gt;, a mistakenly empty or unmounted source can wipe the destination prefix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Guard against that and handle exit code 2:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Refuse to sync from an unmounted or empty source
mountpoint -q /srv/app || { log "source not mounted, aborting"; exit 1; }   # if SRC lives on its own mount
[ -n "$(ls -A "$SRC")" ] || { log "source empty, aborting"; exit 1; }

rc=0
aws s3 sync "$SRC" "$BUCKET/mirror/" --delete --only-show-errors || rc=$?
# rc 1 = transfer failed, rc 2 = some files skipped (e.g. permission denied)
[ "$rc" -eq 0 ] || { log "sync exit code $rc"; exit "$rc"; }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;|| rc=$?&lt;/code&gt; form captures the status without tripping &lt;code&gt;set -e&lt;/code&gt; or the ERR trap.&lt;/p&gt;

&lt;p&gt;For history, enable S3 versioning and a lifecycle rule on the bucket rather than relying on script-side retention. Versioning alone does not stop someone holding credentials that can delete object versions. For ransomware protection, add S3 Object Lock as well. Flag details can change between CLI releases, so verify with the &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/s3/sync.html" rel="noopener noreferrer"&gt;aws s3 sync reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Then stop the log from growing. A short job that appends with &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; works fine with a standard logrotate rule:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# /etc/logrotate.d/backup
/var/log/backup/backup.log {
    su backup backup
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0640 backup backup
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;su&lt;/code&gt; line is needed because logrotate refuses to rotate in a directory writable by a non-root user.&lt;/p&gt;

&lt;p&gt;If the writer is a long-running process that keeps the file descriptor open, add &lt;code&gt;copytruncate&lt;/code&gt;. Otherwise it keeps writing to the renamed file.&lt;/p&gt;

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

&lt;p&gt;Backups fail silently by default, so build the checks into the process rather than trusting memory.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test under cron conditions.&lt;/strong&gt; Run the script with &lt;code&gt;env -i&lt;/code&gt; after every change, and once as the actual cron user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on absence, not just errors.&lt;/strong&gt; At the end of a successful run, send a ping to a dead-man's-switch service or write a success timestamp metric. A missing ping is the alarm, which also covers the case where cron never started.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore on a schedule.&lt;/strong&gt; At least monthly, download the latest object and verify it with &lt;code&gt;tar -tzf&lt;/code&gt; or a checksum. A backup never restored is an assumption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use least-privilege IAM.&lt;/strong&gt; Scope the role to what each job needs:
&lt;ul&gt;
&lt;li&gt;An archive-only job needs &lt;code&gt;s3:PutObject&lt;/code&gt; on its prefix, plus &lt;code&gt;s3:ListBucket&lt;/code&gt; on the bucket restricted with an &lt;code&gt;s3:prefix&lt;/code&gt; condition.&lt;/li&gt;
&lt;li&gt;A mirror job using &lt;code&gt;--delete&lt;/code&gt; also needs &lt;code&gt;s3:DeleteObject&lt;/code&gt;, scoped to the mirror prefix only.&lt;/li&gt;
&lt;li&gt;With versioning on, deny &lt;code&gt;s3:DeleteObjectVersion&lt;/code&gt; so a buggy run only creates delete markers.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;strong&gt;Consider systemd timers.&lt;/strong&gt; On systemd hosts, a timer with &lt;code&gt;Persistent=true&lt;/code&gt; runs missed jobs after downtime. Its logs land in the journal with exit status.&lt;/li&gt;

&lt;li&gt;
&lt;strong&gt;Keep scripts in version control&lt;/strong&gt; and lint them with ShellCheck in CI.&lt;/li&gt;

&lt;/ul&gt;


&lt;p&gt;For more Bash and infrastructure runbooks, browse the rest of &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/08/ci-cd-quality-gate-mistakes-approvals-and-rollback-pitfalls/" rel="noopener noreferrer"&gt;CI/CD Quality Gate Mistakes: Approvals and Rollback Pitfalls&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/30/build-an-interactive-bash-cli-for-devops-reports/" rel="noopener noreferrer"&gt;Build an Interactive Bash CLI for DevOps Reports&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/07/fix-silent-makefile-target-failures-in-infra-repos/" rel="noopener noreferrer"&gt;Fix Silent Makefile Target Failures in Infra Repos&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>CI/CD Quality Gate Mistakes: Approvals and Rollback Pitfalls</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Thu, 08 Oct 2026 07:02:36 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/cicd-quality-gate-mistakes-approvals-and-rollback-pitfalls-dla</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/cicd-quality-gate-mistakes-approvals-and-rollback-pitfalls-dla</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/08/ci-cd-quality-gate-mistakes-approvals-and-rollback-pitfalls" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A team adds a CI/CD quality gate after a bad release, bolts on a manual approval step for production, and writes "rollback: redeploy the previous version" in the runbook. Everyone feels safer. The pipeline is green, the approval button exists, and the rollback sentence is documented. This retrospective looks at how those three controls typically fail anyway. It draws on documented platform behavior and common design patterns rather than any specific incident.&lt;/p&gt;

&lt;h2&gt;Context: three controls, three different jobs&lt;/h2&gt;



&lt;p&gt;The three controls answer different questions, and mixing them up is where trouble starts. A quality gate answers "is this artifact technically acceptable?" It is automated, repeatable and should give the same verdict twice. A manual approval answers "is it acceptable to release this now?" That is a human judgment about timing, risk and business context. A rollback answers "how do we return to a known-good state when the first two were wrong?"&lt;/p&gt;

&lt;p&gt;Each control is cheap to add as a checkbox and expensive to make meaningful. A gate that never fails, an approval nobody reads and a rollback nobody has run all look like working controls until the day they are needed. A common pattern is that the control exists in configuration but does not constrain anything.&lt;/p&gt;

&lt;p&gt;The sections below cover the three most common ways these controls degrade, followed by a pattern that holds up better. The examples use GitHub Actions and Kubernetes because their behavior is well documented. The same ideas apply to GitLab CI, Jenkins or Argo CD.&lt;/p&gt;

&lt;h2&gt;Common failure 1: the gate that can be skipped, retried or ignored&lt;/h2&gt;

&lt;p&gt;The most frequent weakness is a CI/CD quality gate that is not actually required. A test job runs and fails, but the deploy job still starts. The cause is usually &lt;code&gt;continue-on-error: true&lt;/code&gt;, a misconfigured &lt;code&gt;needs&lt;/code&gt;, or a deploy workflow triggered separately from the test workflow. The dashboard shows red, but nothing stopped the release.&lt;/p&gt;

&lt;p&gt;A second variant is the gate that measures the wrong thing. Coverage thresholds computed over the whole repository barely move when a risky change lands, so the gate passes. A better approach is to gate on new or changed code, plus a small set of meaningful checks: unit tests, a vulnerability scan with an explicit severity threshold, and a policy check on infrastructure changes. That is more honest than one large aggregate number.&lt;/p&gt;

&lt;p&gt;A third variant is flakiness. When a test fails one run in ten, people rerun until green, and the gate trains the team to treat red as noise. Quarantine flaky tests visibly and track them, rather than letting reruns become the workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for path filters on required checks.&lt;/strong&gt; In GitHub, a workflow skipped because of path or branch filtering leaves its required check in a "Pending" state, which blocks the merge. Teams often "fix" this by removing the requirement. Check the current behavior in the GitHub Docs on required status checks, and design a lightweight always-run job instead.&lt;/p&gt;

&lt;p&gt;Also verify that the gate runs against the exact commit being deployed. A gate on the pull request branch says little about the merge result on main if main has moved.&lt;/p&gt;

&lt;h2&gt;Common failure 2: the manual approval that approves nothing&lt;/h2&gt;

&lt;p&gt;Manual approval is usually added as a reaction to risk, and then it drifts into ceremony. The approver gets a notification saying "Deployment to production is waiting." It has no diff, no test summary, no link to the change and no indication of what differs from what is running now. Clicking "approve" becomes the path of least resistance, and approval fatigue sets in over time.&lt;/p&gt;

&lt;p&gt;The deeper problem is approving a pipeline rather than an artifact. The approval may happen first with the build running afterward, or the deploy job may rebuild from source. Either way, the thing that was reviewed is not the thing that ships. A moving base image tag or an unpinned dependency can change the result between approval and deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for self-approval and long waits.&lt;/strong&gt; If the person who triggered the deployment can also approve it, the control is decorative. GitHub environments offer a "prevent self-review" option, and required reviewers are configured per environment. Waiting approvals also time out, and the exact limit is platform-dependent. Verify it in the docs before relying on a deploy that waits over a weekend.&lt;/p&gt;

&lt;p&gt;Approvals also tend to be applied uniformly. Gating a documentation change and a database schema change identically teaches people that the gate carries no information. Reserve human approval for changes that are genuinely hard to reverse or time-sensitive. Let low-risk changes flow through the automated gate alone.&lt;/p&gt;

&lt;h2&gt;Common failure 3: rollback that was never exercised&lt;/h2&gt;

&lt;p&gt;"Roll back" often means "redeploy the previous commit." That is a rebuild, not a rollback. It depends on the build system working, registries being reachable and dependencies resolving the same way. It also depends on the quality gate (or an emergency bypass of it) running under pressure. A true rollback redeploys the previous immutable artifact, ideally by digest, without rebuilding anything.&lt;/p&gt;

&lt;p&gt;Even then, rollback only reverts what the tool tracks. A Kubernetes Deployment rollback restores the previous pod template from the revision history. It does not undo changes to ConfigMaps, Secrets, CRDs or external resources. The retained history is bounded by &lt;code&gt;revisionHistoryLimit&lt;/code&gt;, so check the value your manifests actually set. The &lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#rolling-back-a-deployment" rel="noopener noreferrer"&gt;Kubernetes Deployment documentation&lt;/a&gt; describes what a rollback does and does not revert.&lt;/p&gt;

&lt;p&gt;The hardest case is state. Suppose release N+1 ran a destructive migration, such as dropping a column or rewriting data. Rolling the application back to N may then leave it running against a schema it does not understand. This is often discovered only during an attempted rollback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for rollback paths that need the thing that just broke.&lt;/strong&gt; The rollback may require the same pipeline, the same approver and the same registry. If so, a single outage can block both the release and the recovery. Rehearse the rollback in a non-production environment on a schedule, so the first real attempt is not the first attempt.&lt;/p&gt;

&lt;h2&gt;Safer operating pattern: build once, gate the artifact, verify after deploy&lt;/h2&gt;

&lt;p&gt;A more reliable CI/CD quality gate design follows a few principles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gate the exact commit that produces the artifact, and optionally scan the built image by digest.&lt;/li&gt;
&lt;li&gt;Build the artifact once and identify it by digest.&lt;/li&gt;
&lt;li&gt;Attach the approval to the environment, not to a workflow step.&lt;/li&gt;
&lt;li&gt;Verify health after deploy, and undo automatically when verification fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The sketch below shows the shape in GitHub Actions; adapt names and secrets to your setup.&lt;/p&gt;

&lt;p&gt;This workflow gates the commit, builds one image from that tree, deploys that exact digest to a protected environment, and reverts if rollout verification fails:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;name: release
on:
  push:
    branches: [main]

jobs:
  build-and-gate:
    runs-on: ubuntu-24.04
    outputs:
      digest: ${{ steps.push.outputs.digest }}   # immutable reference reused downstream
    steps:
      - uses: actions/checkout@v5                # pin to a commit SHA in real pipelines
      - name: Unit tests and policy checks
        run: make test lint                      # no continue-on-error: failure stops the run
      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3
      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USERNAME }}   # placeholder secret names
          password: ${{ secrets.REGISTRY_TOKEN }}
      - name: Build and push image
        id: push
        uses: docker/build-push-action@v6
        with:
          context: .                             # build the tree that was just tested
          push: true
          tags: registry.example.com/app:${{ github.sha }}

  deploy-prod:
    needs: build-and-gate
    runs-on: ubuntu-24.04
    environment: production                      # required reviewers + prevent self-review live here
    steps:
      - name: Configure cluster access
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_PROD }}  # placeholder; prefer OIDC short-lived creds
        run: |
          mkdir -p ~/.kube
          printf '%s' "$KUBECONFIG_DATA" &amp;gt; ~/.kube/config
      - name: Deploy by digest
        run: |
          kubectl set image deployment/app \
            app=registry.example.com/app@${{ needs.build-and-gate.outputs.digest }}
      - name: Verify rollout
        id: verify
        run: kubectl rollout status deployment/app --timeout=180s
      - name: Roll back on failed verification
        if: failure() &amp;amp;&amp;amp; steps.verify.outcome == 'failure'   # only undo a rollout that actually started
        run: kubectl rollout undo deployment/app
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Environment protection rules, including required reviewers, are configured in repository settings rather than in the YAML. See the &lt;a href="https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment" rel="noopener noreferrer"&gt;GitHub Docs on deployment environments&lt;/a&gt; for current options and plan limitations.&lt;/p&gt;

&lt;p&gt;The rollback condition is deliberately narrow. If the deploy step itself fails, no new revision exists, and an unconditional &lt;code&gt;rollout undo&lt;/code&gt; would revert to an even older release. Note also that &lt;code&gt;rollout status&lt;/code&gt; only proves pods became ready, so add a real smoke test or metric check for anything user-facing.&lt;/p&gt;

&lt;p&gt;The following checklist captures the decisions that matter before calling the pipeline production-ready:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;GATE
[ ] Required check on the protected branch (not just "runs")
[ ] Always-run job so path filters cannot leave it pending
[ ] Thresholds apply to changed code; flaky tests quarantined, not retried

APPROVAL
[ ] Attached to the environment, shown with diff + test summary
[ ] Approver is not the deployer; timeout behavior known
[ ] Approves a digest, never a rebuild

ROLLBACK
[ ] Previous artifact still in registry (retention checked)
[ ] Migrations follow expand/contract; N-1 code works with N schema
[ ] Rollback rehearsed in staging; works without bypassing the gate
[ ] Post-deploy verification triggers it automatically
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The migration line deserves emphasis. Expand/contract means three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the new schema first.&lt;/li&gt;
&lt;li&gt;Ship code that tolerates both shapes.&lt;/li&gt;
&lt;li&gt;Remove the old shape in a later release.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That keeps the previous application version valid. It makes rollback a routine operation instead of a data-recovery project.&lt;/p&gt;

&lt;p&gt;More pipeline design notes are available on the &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS home page&lt;/a&gt;. None of this removes the need for judgment. It only ensures that the gate, the approval and the rollback each do the one job they were added for.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/04/3-boto3-and-argparse-mistakes-that-break-python-aws-scripts/" rel="noopener noreferrer"&gt;3 boto3 and argparse Mistakes That Break Python AWS Scripts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/14/terraform-drift-detection-in-ci-building-a-remediation-pipeline/" rel="noopener noreferrer"&gt;Terraform Drift Detection in CI: Building a Remediation Pipeline&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/07/fix-silent-makefile-target-failures-in-infra-repos/" rel="noopener noreferrer"&gt;Fix Silent Makefile Target Failures in Infra Repos&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>DataSync EventBridge Automation Checklist for S3 Transfers</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Wed, 07 Oct 2026 07:02:34 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/datasync-eventbridge-automation-checklist-for-s3-transfers-3lkc</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/datasync-eventbridge-automation-checklist-for-s3-transfers-3lkc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/07/datasync-eventbridge-automation-checklist-for-s3-transfers" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A nightly copy from an on-premises NFS share into S3 works fine for weeks, then stops. Nobody notices until a downstream report is empty, because the task failed quietly and no alarm was wired to it. DataSync EventBridge automation exists to close that gap. It turns transfer state changes into events, and events into schedules, alerts, and follow-up jobs.&lt;/p&gt;

&lt;p&gt;This checklist is for engineers who already have a DataSync task running manually and want it to run unattended. It assumes S3 as the destination. Details such as event field names and quotas change over time, so verify them against the &lt;a href="https://docs.aws.amazon.com/datasync/latest/userguide/what-is-datasync.html" rel="noopener noreferrer"&gt;DataSync user guide&lt;/a&gt; before you rely on them.&lt;/p&gt;

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



&lt;p&gt;Many unattended DataSync failures are not service problems. They come from missing glue: nothing starts the task on time, nothing tells anyone when it fails, and nothing reacts when the data lands. Each gap is small, and each is easy to close once you know where to look.&lt;/p&gt;

&lt;p&gt;A checklist suits this topic better than a tutorial because the pieces are independent. You can set up scheduling, eventing, permissions, and cost controls in any order, and skipping one rarely causes an error on day one. The failure shows up later, usually on a night when something unusual happens. A source mount might go offline, or a task execution might overlap with the previous one.&lt;/p&gt;

&lt;p&gt;The pattern covered here has three parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trigger:&lt;/strong&gt; EventBridge Scheduler starts the task, or an upstream event does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observe:&lt;/strong&gt; DataSync task execution state changes flow to EventBridge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React:&lt;/strong&gt; S3 object events or a successful execution kick off processing, and failures page someone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even the observe step alone removes a significant blind spot.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decide who owns the schedule.&lt;/strong&gt; DataSync has a built-in task schedule, and EventBridge Scheduler can call &lt;code&gt;StartTaskExecution&lt;/code&gt; directly. Pick one. Running both produces overlapping executions that are hard to explain later. Scheduler gives you time zones and flexible windows, so it is often the better choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a dedicated Scheduler role.&lt;/strong&gt; It needs &lt;code&gt;datasync:StartTaskExecution&lt;/code&gt; on the specific task ARN and nothing broader. If you configure a dead-letter queue, it also needs &lt;code&gt;sqs:SendMessage&lt;/code&gt; on that queue. Its trust policy must allow &lt;code&gt;scheduler.amazonaws.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create the schedule with the universal target.&lt;/strong&gt; The example below uses the AWS SDK target, so you do not need a Lambda function just to start a task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable EventBridge delivery on the destination bucket.&lt;/strong&gt; S3 sends events to EventBridge only when the bucket is configured for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a failure rule first.&lt;/strong&gt; Match DataSync task execution state changes that end in an error. Route them to SNS, a chat webhook, or an incident tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a success rule second.&lt;/strong&gt; Use it to trigger post-processing, such as a validation job, a catalog update, or a manifest write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope S3 object rules by prefix.&lt;/strong&gt; Filter on bucket name and key prefix so unrelated uploads do not trigger your pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on CloudWatch Logs for the task.&lt;/strong&gt; Set the log level deliberately. Basic is usually enough for routine runs, while transfer-level detail costs more to store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable task reports&lt;/strong&gt; to an S3 location if you need per-file evidence of what was transferred, skipped, or failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle target failures explicitly.&lt;/strong&gt; A dead-letter queue and retry policy on a rule or schedule target only catches events that could not be delivered. Throttling and missing permissions are typical causes. Errors inside your own code are a separate case. For Lambda, which EventBridge invokes asynchronously, configure an on-failure destination or a function-level dead-letter queue as well. A target that fails silently is the same blind spot you started with.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;First, the failure rule. This event pattern matches DataSync task execution errors. Verify the exact detail-type and field names against the current EventBridge event reference for DataSync.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "source": ["aws.datasync"],
  "detail-type": ["DataSync Task Execution State Change"],
  "detail": {
    "State": ["ERROR"]
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Next, the schedule. This CLI call starts a task nightly in a named time zone. It uses the SDK target, so no compute is involved. It also sends failed start attempts to an SQS dead-letter queue, for example when the call is rejected or throttled.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;aws scheduler create-schedule \
  --name nightly-datasync-nfs-to-s3 \
  --schedule-expression "cron(0 2 * * ? *)" \
  --schedule-expression-timezone "Europe/Berlin" \
  --flexible-time-window Mode=OFF \
  --target '{
    "Arn": "arn:aws:scheduler:::aws-sdk:datasync:startTaskExecution",
    "RoleArn": "arn:aws:iam::111122223333:role/scheduler-datasync-start",
    "Input": "{\"TaskArn\":\"arn:aws:datasync:eu-central-1:111122223333:task/task-0example\"}",
    "RetryPolicy": {"MaximumRetryAttempts": 2, "MaximumEventAgeInSeconds": 3600},
    "DeadLetterConfig": {"Arn": "arn:aws:sqs:eu-central-1:111122223333:scheduler-datasync-dlq"}
  }'
# Input is a JSON string inside JSON, so the inner quotes must be escaped&lt;/code&gt;&lt;/pre&gt;

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

&lt;p&gt;These are documented behaviors and typical failure modes that tend to surface after the pipeline has been live for a while.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for bucket notification overwrites.&lt;/strong&gt; The S3 API call that enables EventBridge delivery replaces the bucket's whole notification configuration. If the bucket already has Lambda or SQS notifications, sending only &lt;code&gt;{"EventBridgeConfiguration":{}}&lt;/code&gt; removes them. Read the current configuration first and merge. Infrastructure-as-code tools can also fight each other over this one setting.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Read existing config before changing anything
aws s3api get-bucket-notification-configuration --bucket landing-bucket

# Then write back the merged result, including the EventBridge key
aws s3api put-bucket-notification-configuration \
  --bucket landing-bucket \
  --notification-configuration file://merged-notifications.json&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for event loops.&lt;/strong&gt; If a rule triggers a job that writes back into the same bucket and prefix, each write emits another object event. Separate the input and output prefixes, or use a different bucket, and make the rule's prefix filter strict.&lt;/p&gt;

&lt;p&gt;Other items that regularly get skipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Success does not mean complete data.&lt;/strong&gt; A task can finish while skipping files, depending on its filters and options. Check the verification settings. Where it matters, compare counts from the task report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overlapping executions.&lt;/strong&gt; A task generally runs one execution at a time. What happens when a second start is requested depends on current service rules. Check the documented quotas and queuing behavior rather than assuming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service-created objects.&lt;/strong&gt; DataSync can write helper or metadata objects to destinations. Look at what actually lands in your bucket and exclude those objects from your S3 rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage class choice.&lt;/strong&gt; Writing straight into an archival class carries minimum storage duration charges. If the data is reprocessed soon, that can cost more than a standard class plus a lifecycle rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-account and KMS.&lt;/strong&gt; A bucket in another account, or one encrypted with a customer managed key, needs grants for the DataSync location role. Grant access in both the bucket policy and the key policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Region mismatch.&lt;/strong&gt; DataSync and S3 events are delivered to the default event bus in the Region where they occur. The rule that matches them must live in that Region. From there, you can forward events to a bus in another Region if your alerting is centralized.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Once the basics are stable, a few small additions add value without growing the architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chain stages with events, not timers.&lt;/strong&gt; Do not schedule a processing job at 03:00 and hope the transfer finished. Instead, match the success state change and start the next stage from that. This removes the guessing and the fragile time gaps. A Step Functions state machine works well as the target when the follow-up has several steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use input overrides for ad-hoc runs.&lt;/strong&gt; &lt;code&gt;StartTaskExecution&lt;/code&gt; accepts include and exclude filters and option overrides. A second schedule can transfer only a recent prefix, such as the current day's folder, while the nightly run does the full pass. Stagger the two so they do not overlap, because both start executions of the same task. Verify the filter syntax in the DataSync documentation, since patterns are pipe-delimited and easy to get subtly wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build a freshness check.&lt;/strong&gt; An event-driven alarm tells you about failures but not about a task that never started. Have the success rule's target publish a custom CloudWatch metric on each successful execution. Then alarm on that metric over your expected interval, with missing data treated as breaching. This catches disabled schedules, broken Scheduler permissions, and offline agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Define everything in code.&lt;/strong&gt; Keep the task, schedule, role, rules, and bucket notification in one Terraform module or CloudFormation stack, so the notification overwrite problem has a single owner. For more AWS automation patterns, browse the rest of &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tag and route by environment.&lt;/strong&gt; Use rule input transformers so one alerting target can name the failed task and execution in its message. In DataSync execution events, the execution ARN appears in the event's &lt;code&gt;resources&lt;/code&gt; array rather than in &lt;code&gt;detail&lt;/code&gt;, so map it from there. Responders then see which job failed without opening the console. The &lt;a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-what-is.html" rel="noopener noreferrer"&gt;EventBridge user guide&lt;/a&gt; covers input transformers, retry policies, and dead-letter queues in detail.&lt;/p&gt;

&lt;p&gt;Start with the failure rule and the freshness check. Together they cover both a failed run and a run that never started, which are two of the easiest ways for this pipeline to fail unnoticed.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/28/github-actions-oidc-to-aws-dropping-long-lived-access-keys/" rel="noopener noreferrer"&gt;GitHub Actions OIDC to AWS: dropping long-lived access keys&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/27/oidc-federation-and-what-it-actually-does-for-ci-on-aws/" rel="noopener noreferrer"&gt;OIDC federation and what it actually does for CI on AWS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/21/irsa-explained-how-eks-pods-borrow-aws-permissions/" rel="noopener noreferrer"&gt;IRSA explained: how EKS pods borrow AWS permissions&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>Cloudflare Tunnel Shows 502 or Inactive? Troubleshooting Fixes</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Tue, 06 Oct 2026 07:02:39 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/cloudflare-tunnel-shows-502-or-inactive-troubleshooting-fixes-4db5</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/cloudflare-tunnel-shows-502-or-inactive-troubleshooting-fixes-4db5</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/06/cloudflare-tunnel-shows-502-or-inactive-troubleshooting-fixes" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The change window is open, the internal dashboard is supposed to be reachable at a public hostname with no inbound firewall rules, and the Cloudflare Tunnel you created an hour ago shows "Inactive" or returns a 502 to every browser. Nothing is listening on a public port, so there is no obvious place to look. This runbook covers the failure modes of Cloudflare Tunnel (formerly Argo Tunnel, still run by the &lt;code&gt;cloudflared&lt;/code&gt; daemon) in the order they are usually diagnosed.&lt;/p&gt;

&lt;h2&gt;Symptoms&lt;/h2&gt;



&lt;p&gt;Match what you see to the list below before changing anything. Each symptom points at a different layer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Error 1033 in the browser&lt;/strong&gt; – Cloudflare resolved the hostname to a tunnel, but no &lt;code&gt;cloudflared&lt;/code&gt; connector is attached to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error 1016 (origin DNS error)&lt;/strong&gt; – the DNS record points at a tunnel UUID that was deleted or never existed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;502 Bad Gateway&lt;/strong&gt; – the tunnel is connected, but &lt;code&gt;cloudflared&lt;/code&gt; could not get a valid response from the local service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;404 with an empty body&lt;/strong&gt; – the request reached &lt;code&gt;cloudflared&lt;/code&gt; but fell through to the catch-all ingress rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tunnel flaps between Healthy and Degraded&lt;/strong&gt; – connections to the edge drop and reconnect, often with timeout messages in the logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with the connector's own view of the world. The &lt;code&gt;list&lt;/code&gt; and &lt;code&gt;info&lt;/code&gt; subcommands need the account certificate (&lt;code&gt;cert.pem&lt;/code&gt;) created by &lt;code&gt;cloudflared tunnel login&lt;/code&gt;. On hosts that only have a tunnel token, check the tunnel's status and connectors in the dashboard instead.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# List tunnels and see which have active connections (requires cert.pem)
cloudflared tunnel list

# Show connector IDs, edge locations and origin IPs for one tunnel
cloudflared tunnel info my-internal-apps

# Read recent logs (systemd install)
journalctl -u cloudflared --since "30 min ago" --no-pager
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If &lt;code&gt;info&lt;/code&gt; shows no connectors, you have a connector problem. If it shows connectors but users still see errors, the problem is routing or the origin.&lt;/p&gt;

&lt;h2&gt;Root cause&lt;/h2&gt;

&lt;p&gt;A Cloudflare Tunnel has three independent links, and each can fail while the other two look fine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Edge link.&lt;/strong&gt; The outbound connection from &lt;code&gt;cloudflared&lt;/code&gt; to Cloudflare's edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostname mapping.&lt;/strong&gt; The mapping from a public hostname to a service. It lives either in the dashboard (remotely managed tunnels) or in a local &lt;code&gt;config.yml&lt;/code&gt; (locally managed tunnels).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin link.&lt;/strong&gt; The connection from &lt;code&gt;cloudflared&lt;/code&gt; to the actual origin service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Many failures trace back to one of these documented behaviors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cloudflared&lt;/code&gt; only makes outbound connections. If egress to the edge is blocked (the documented port is 7844, TCP and UDP), it never registers and you get 1033.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;localhost&lt;/code&gt; means "this process's own network namespace". Inside a container, it is not the host and not the neighboring container.&lt;/li&gt;
&lt;li&gt;Ingress rules are evaluated top to bottom, and the last rule must be a catch-all. A hostname typo sends traffic to that fallback.&lt;/li&gt;
&lt;li&gt;A service URL with &lt;code&gt;https://&lt;/code&gt; and a self-signed or mismatched certificate fails TLS verification and surfaces as 502.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; mixing management modes. If a tunnel was created in the dashboard (token-based), the dashboard configuration is authoritative and a local &lt;code&gt;config.yml&lt;/code&gt; ingress section is ignored. Editing the wrong one is a common reason a "fix" changes nothing. Verify the current behavior in the &lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/" rel="noopener noreferrer"&gt;official Cloudflare Tunnel documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;Fix #1: Restore the connector (1033 and Inactive status)&lt;/h2&gt;

&lt;p&gt;First confirm the process is running and can reach the edge. A connector that never registers is usually a firewall, proxy, or stale-credential issue.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Is the service up?
systemctl status cloudflared

# Can this host reach the edge on the tunnel port over TCP?
# (hostnames per Cloudflare's firewall docs; verify the current list)
# Note: this does not prove that UDP 7844 (QUIC) is open.
nc -vz region1.v2.argotunnel.com 7844
nc -vz region2.v2.argotunnel.com 7844

# Force HTTP/2 over TCP to rule out filtered UDP (QUIC)
# Locally managed tunnel:
cloudflared tunnel --protocol http2 run my-internal-apps
# Token-based (remotely managed) tunnel:
cloudflared tunnel --protocol http2 run --token &amp;lt;TOKEN&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The default protocol setting is &lt;code&gt;auto&lt;/code&gt;. It tries QUIC, which uses UDP, first and can fall back to HTTP/2 over TCP. Some corporate firewalls and cloud security groups allow outbound TCP only. In those networks the connector may fail or reconnect repeatedly, so forcing &lt;code&gt;http2&lt;/code&gt; is a quick way to confirm that diagnosis. If it works, choose one of two permanent fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open outbound UDP 7844.&lt;/li&gt;
&lt;li&gt;Set the protocol in your configuration, using &lt;code&gt;protocol: http2&lt;/code&gt; in &lt;code&gt;config.yml&lt;/code&gt; or the &lt;code&gt;--protocol&lt;/code&gt; flag in the service definition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other connector-level causes to rule out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An expired or rotated token in the service unit. Run &lt;code&gt;cloudflared service uninstall&lt;/code&gt;, then reinstall with &lt;code&gt;cloudflared service install &amp;lt;TOKEN&amp;gt;&lt;/code&gt; using a fresh token from the dashboard.&lt;/li&gt;
&lt;li&gt;A very old &lt;code&gt;cloudflared&lt;/code&gt; binary. Cloudflare supports only recent releases, so update to the current one and check the release notes for your version.&lt;/li&gt;
&lt;li&gt;Clock drift on the host, which can break TLS to the edge. Check NTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If 1016 appears instead, the DNS CNAME points at a deleted tunnel. For a remotely managed tunnel, re-create the public hostname in the dashboard. For a locally managed tunnel, run &lt;code&gt;cloudflared tunnel route dns --overwrite-dns my-internal-apps app.example.com&lt;/code&gt; so the record targets the live tunnel ID.&lt;/p&gt;

&lt;h2&gt;Fix #2: Correct the ingress and the origin address (502 and unexpected 404)&lt;/h2&gt;

&lt;p&gt;When connectors are healthy, the next suspect is the service URL. For a locally managed tunnel, validate the file before restarting anything. The example below shows the pieces that most often go wrong.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# /etc/cloudflared/config.yml (locally managed tunnel)
tunnel: 6ff42ae2-0000-0000-0000-example     # tunnel UUID, placeholder
credentials-file: /etc/cloudflared/6ff42ae2-0000-0000-0000-example.json

ingress:
  - hostname: app.example.com
    service: http://localhost:8080          # plain HTTP to a local process
  - hostname: grafana.example.com
    service: https://localhost:3000
    originRequest:
      noTLSVerify: true                     # only for self-signed certs you accept; prefer caPool
  - hostname: ssh.example.com
    service: ssh://localhost:22
  - service: http_status:404                # mandatory catch-all, must be last
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then test how a specific URL would be routed, without sending real traffic:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;cloudflared tunnel ingress validate
cloudflared tunnel ingress rule https://app.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The second command prints which rule matches. If it prints the catch-all, the hostname in the rule does not match what the user typed. Fix the spelling or add a wildcard rule.&lt;/p&gt;

&lt;p&gt;For 502 specifically, test from the same host (or container) running &lt;code&gt;cloudflared&lt;/code&gt;: &lt;code&gt;curl -v http://localhost:8080&lt;/code&gt;. If that fails, the problem is the application, not the tunnel. If you use HTTPS to the origin, set &lt;code&gt;originServerName&lt;/code&gt; to match the certificate's name, or supply a CA with &lt;code&gt;caPool&lt;/code&gt; rather than disabling verification permanently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; &lt;code&gt;noTLSVerify: true&lt;/code&gt; silences the error but removes protection on the hop between &lt;code&gt;cloudflared&lt;/code&gt; and the origin. Treat it as a temporary diagnostic, not a final setting.&lt;/p&gt;

&lt;h2&gt;Fix #3: Repair container networking and unstable connections&lt;/h2&gt;

&lt;p&gt;In Docker or Kubernetes, &lt;code&gt;localhost&lt;/code&gt; in an ingress rule is a very common 502 cause. Point the service at the container or Kubernetes Service name instead, and keep both on the same network.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# compose.yaml (no top-level "version" key needed in current Compose)
services:
  cloudflared:
    image: cloudflare/cloudflared:${CLOUDFLARED_VERSION:-latest}   # pin CLOUDFLARED_VERSION in .env
    command: tunnel --no-autoupdate run      # token comes from the environment
    environment:
      TUNNEL_TOKEN: ${TUNNEL_TOKEN}          # keep in .env or a secret store, never in git
    restart: unless-stopped
    networks: [internal]
  app:
    image: ghcr.io/example/app:1.4.2         # placeholder application image
    networks: [internal]
networks:
  internal: {}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;With a token-based tunnel, set the public hostname's service in the dashboard to &lt;code&gt;http://app:8080&lt;/code&gt;. In Kubernetes, use &lt;code&gt;http://my-svc.my-namespace.svc.cluster.local:8080&lt;/code&gt;. Set &lt;code&gt;CLOUDFLARED_VERSION&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; to a specific current release tag from the official registry. The &lt;code&gt;latest&lt;/code&gt; fallback only exists so the file runs as written, so do not rely on it in production.&lt;/p&gt;

&lt;p&gt;For flapping connections, check three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replicas.&lt;/strong&gt; Run at least two replicas of &lt;code&gt;cloudflared&lt;/code&gt; so a single restart does not take the route down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource limits.&lt;/strong&gt; Look for limits that cause OOM kills on small containers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics and readiness.&lt;/strong&gt; Inspect the connector metrics endpoint (the &lt;code&gt;--metrics&lt;/code&gt; flag sets the address) and its &lt;code&gt;/ready&lt;/code&gt; path, which reports whether the connector currently has edge connections.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Most of these failures are preventable with a few habits. Put them in your tunnel template once and reuse it across environments, the same way you would standardize other infrastructure patterns covered on &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick one management mode per tunnel.&lt;/strong&gt; Prefer remotely managed tunnels with Terraform or the API so configuration is reviewable, and avoid editing both places.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run two or more connectors&lt;/strong&gt; on separate hosts or nodes for every production tunnel, and roll updates one at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow egress explicitly.&lt;/strong&gt; Document outbound port 7844 (TCP and UDP) in your firewall or security-group baseline, and decide on protocol deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin and update &lt;code&gt;cloudflared&lt;/code&gt;.&lt;/strong&gt; Use a pinned image tag, and review the release notes on a schedule so you never fall outside the supported window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on tunnel health.&lt;/strong&gt; Use Cloudflare's tunnel health notifications, plus a probe of the public hostname, so you hear about 1033 before users do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect the origin hop and the hostname.&lt;/strong&gt; Put Cloudflare Access in front of internal apps, and keep TLS verification on between &lt;code&gt;cloudflared&lt;/code&gt; and the origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat tokens as secrets.&lt;/strong&gt; Store them in a secret manager, rotate them when staff change, and never bake them into images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before closing the ticket, run through a final checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tunnel info&lt;/code&gt; (or the dashboard) shows connectors.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ingress rule&lt;/code&gt; matches the intended service on locally managed tunnels.&lt;/li&gt;
&lt;li&gt;A local &lt;code&gt;curl&lt;/code&gt; to the origin succeeds.&lt;/li&gt;
&lt;li&gt;The public hostname returns the expected status.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Details can change between releases, so confirm flags and error-code meanings against the &lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/" rel="noopener noreferrer"&gt;Cloudflare Tunnel documentation&lt;/a&gt; for your &lt;code&gt;cloudflared&lt;/code&gt; version.&lt;/p&gt;

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

&lt;ul&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;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/18/cloudflare-cache-bypass-mistakes-on-dynamic-wordpress-paths/" rel="noopener noreferrer"&gt;Cloudflare cache bypass mistakes on dynamic WordPress paths&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;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Cisco Config Backups with Ansible: ios_config vs ios_command</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Mon, 05 Oct 2026 07:02:34 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/cisco-config-backups-with-ansible-iosconfig-vs-ioscommand-2hg9</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/cisco-config-backups-with-ansible-iosconfig-vs-ioscommand-2hg9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/10/05/cisco-config-backups-with-ansible-ios-config-vs-ios-command" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Cisco config backups usually start the same way: someone replaces a failed access switch, opens a ticket for the last known-good configuration, and finds a TFTP folder that stopped updating a year ago. Ansible can fix that in an afternoon, but the first design decision is how the configuration gets from the device to disk. There are two common approaches, and they behave differently once you add Git, compliance reviews, or a few hundred switches.&lt;/p&gt;

&lt;h2&gt;When Cisco config backups need a deliberate choice&lt;/h2&gt;



&lt;p&gt;For a dozen switches and a nightly job, either approach works and the choice barely matters. It starts to matter when you want meaningful diffs, when the backup files contain secrets, or when auditors ask who changed what and when.&lt;/p&gt;

&lt;p&gt;Three questions decide it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What do you do with the file afterward?&lt;/strong&gt; Archiving timestamped copies is a different job from feeding a Git repository that alerts on drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How noisy is the raw output?&lt;/strong&gt; IOS and IOS XE prepend lines such as the current-configuration byte count and the last-change and NVRAM-update timestamps. These change whenever the configuration is edited or saved. Some platforms also include lines such as &lt;code&gt;ntp clock-period&lt;/code&gt; that drift on their own, so diffs can show changes nobody made intentionally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who maintains the playbook?&lt;/strong&gt; A single module call is easy to hand over. A task chain with text processing needs tests and an owner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both options below use the &lt;code&gt;cisco.ios&lt;/code&gt; collection over &lt;code&gt;ansible.netcommon.network_cli&lt;/code&gt;. Install it with &lt;code&gt;ansible-galaxy collection install cisco.ios&lt;/code&gt; and check the collection's documented minimum &lt;code&gt;ansible-core&lt;/code&gt; version, since it changes between releases. This shared inventory setup works for both:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# group_vars/cisco_switches.yml
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ansible_user: "{{ vault_net_user }}"        # keep credentials in Ansible Vault
ansible_password: "{{ vault_net_pass }}"
ansible_become: true
ansible_become_method: enable               # privilege level 15 via enable
ansible_become_password: "{{ vault_enable_pass }}"
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Option A: ios_config with backup: true&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;cisco.ios.ios_config&lt;/code&gt; module has a documented &lt;code&gt;backup&lt;/code&gt; parameter that fetches the running configuration and writes it to the controller. With no &lt;code&gt;lines&lt;/code&gt; or &lt;code&gt;src&lt;/code&gt; given, it changes nothing on the device. That is the whole playbook:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# backup_native.yml
- name: Back up Cisco IOS running configs
  hosts: cisco_switches
  gather_facts: false            # no Python on the switch, so skip fact gathering
  tasks:
    - name: Save running-config via the module
      cisco.ios.ios_config:
        backup: true
        backup_options:
          dir_path: "/srv/net-backups/{{ inventory_hostname }}"
          filename: "running-config.cfg"   # fixed name suits Git; omit for timestamped files
&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;Minimal code, maintained with the collection, and the intent is obvious to anyone reading it.&lt;/li&gt;
&lt;li&gt;Without &lt;code&gt;backup_options&lt;/code&gt;, it writes timestamped files to a &lt;code&gt;backup/&lt;/code&gt; directory next to the playbook, a reasonable archive with no extra work.&lt;/li&gt;
&lt;li&gt;Low risk of a bad regex or templating error corrupting a backup.&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;You get the configuration as the module returns it. Volatile header lines may still show up in diffs, so verify the actual output on your platform and release.&lt;/li&gt;
&lt;li&gt;It covers the running configuration. Startup-config, VLAN databases, or other &lt;code&gt;show&lt;/code&gt; output need separate tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch out for&lt;/strong&gt; the file contents. Backups can include hashed enable secrets, type 7 passwords, and SNMP community strings. Restrict directory permissions and treat the repository as sensitive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Option B: ios_command plus your own file handling&lt;/h2&gt;

&lt;p&gt;Here you run &lt;code&gt;show running-config&lt;/code&gt; through &lt;code&gt;cisco.ios.ios_command&lt;/code&gt; and write the result yourself from the controller. It is more work, but you control exactly what reaches disk. For example, you can strip lines that change without an intentional edit so Git only records real changes. Unlike the module's backup option, &lt;code&gt;copy&lt;/code&gt; does not create missing parent directories, so the playbook creates the target directory first:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# backup_custom.yml
- name: Back up and normalize Cisco configs
  hosts: cisco_switches
  gather_facts: false
  tasks:
    - name: Ensure backup directory exists on the controller
      ansible.builtin.file:
        path: /srv/net-backups
        state: directory
        mode: "0700"
      delegate_to: localhost
      run_once: true

    - name: Collect running-config
      cisco.ios.ios_command:
        commands: show running-config
      register: run_cfg

    - name: Write normalized file on the controller
      ansible.builtin.copy:
        dest: "/srv/net-backups/{{ inventory_hostname }}.cfg"
        mode: "0600"                         # configs contain secrets
        content: |
          {{ run_cfg.stdout[0] | regex_replace('(?m)^(Building configuration|Current configuration|!\s*Last configuration change|!\s*NVRAM config last updated|ntp clock-period).*$\n?', '') }}
      delegate_to: localhost                 # otherwise copy targets the switch
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Stripping &lt;code&gt;ntp clock-period&lt;/code&gt; is a judgment call. It is a real configuration line, but the device adjusts it on its own, so keeping it mostly adds noise to the diff.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Full control over normalization, file mode, naming, and which commands run. Adding &lt;code&gt;show startup-config&lt;/code&gt; or &lt;code&gt;show version&lt;/code&gt; means one more entry in &lt;code&gt;commands&lt;/code&gt; plus a write task that reads the matching &lt;code&gt;stdout&lt;/code&gt; index.&lt;/li&gt;
&lt;li&gt;Clean, stable output makes Git history and drift alerts far more useful.&lt;/li&gt;
&lt;li&gt;The same pattern extends to other platforms using &lt;code&gt;ansible.netcommon.cli_command&lt;/code&gt; or their own collections.&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;You own the regex. Header lines differ across IOS, IOS XE, and release trains, so verify against real output before trusting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch out for&lt;/strong&gt; &lt;code&gt;delegate_to: localhost&lt;/code&gt;. Omit it and &lt;code&gt;copy&lt;/code&gt; runs against the device over the network connection, where it may fail or behave unpredictably.&lt;/li&gt;
&lt;li&gt;Over-aggressive normalization can hide a real change. Keep the pattern narrow and review it when platforms change.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Match your situation to the row that hurts most. Neither option is wrong; they trade simplicity for control.&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;Option A: ios_config backup&lt;/th&gt;
&lt;th&gt;Option B: ios_command + copy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Small estate, archive only&lt;/td&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git history with drift alerts&lt;/td&gt;
&lt;td&gt;Works if output is already stable&lt;/td&gt;
&lt;td&gt;Better: normalize before commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need startup-config or extra show output&lt;/td&gt;
&lt;td&gt;Needs extra tasks anyway&lt;/td&gt;
&lt;td&gt;Natural fit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed-skill team&lt;/td&gt;
&lt;td&gt;Easier to hand over&lt;/td&gt;
&lt;td&gt;Needs an owner and tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strict file permissions and naming rules&lt;/td&gt;
&lt;td&gt;Limited to module options&lt;/td&gt;
&lt;td&gt;Full control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-vendor estate&lt;/td&gt;
&lt;td&gt;Similar backup options exist in other vendors' *_config modules, configured per platform&lt;/td&gt;
&lt;td&gt;One pattern reusable across platforms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A quick checklist to run before you commit to either design:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# pre-flight checklist
[ ] Backup target directory is outside the playbook repo, or encrypted
[ ] Files are mode 0600 and owned by the automation user
[ ] One sample switch per platform family diffed across two runs with no changes
[ ] Volatile header lines confirmed absent (or stripped) in the second run
[ ] Credentials come from Ansible Vault or an external secrets backend
[ ] Job scheduled (AWX/AAP, cron, or CI schedule) with failure notifications
&lt;/code&gt;&lt;/pre&gt;

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

&lt;p&gt;Start with Option A. It is documented behavior, a few lines long, and it removes the real problem, which is that nobody is backing up at all. Run it on a schedule, store the output somewhere access-controlled, and confirm you can actually read a restored file.&lt;/p&gt;

&lt;p&gt;Move to Option B when a specific problem forces it. The usual triggers are noisy diffs in Git, a requirement to capture more than the running configuration, or a file-handling rule the module cannot express. Do not start there because it feels more flexible. Every regex you write is a maintenance commitment, and a typical failure is a normalization pattern that silently stops matching after an OS upgrade.&lt;/p&gt;

&lt;p&gt;A hybrid is also legitimate. Use Option A for the nightly archive and add a separate Option B job that produces normalized files for the Git repository. Keeping the raw archive means that if the normalizer ever hides something, the original is still on disk.&lt;/p&gt;

&lt;p&gt;Whichever you pick, check the parameter list for your installed collection version in the official &lt;a href="https://docs.ansible.com/ansible/latest/collections/cisco/ios/ios_config_module.html" rel="noopener noreferrer"&gt;cisco.ios.ios_config documentation&lt;/a&gt;, since defaults and &lt;code&gt;backup_options&lt;/code&gt; behavior can change between releases. For more automation patterns, browse the rest of &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/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/20/bgp-routing-basics-for-engineers-tired-of-traffic-drops/" rel="noopener noreferrer"&gt;BGP routing basics for engineers tired of traffic drops&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/2026/09/19/wordpress-performance-profiling-with-php-fpm-status-and-slow-logs/" rel="noopener noreferrer"&gt;WordPress performance profiling with php-fpm status and slow logs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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