<?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: SecOpsLog</title>
    <description>The latest articles on DEV Community by SecOpsLog (secopsloghq).</description>
    <link>https://dev.to/secopsloghq</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%2Forganization%2Fprofile_image%2F13978%2Fcba34871-ef60-407d-ac7e-5a95ee6d869b.png</url>
      <title>DEV Community: SecOpsLog</title>
      <link>https://dev.to/secopsloghq</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/secopsloghq"/>
    <language>en</language>
    <item>
      <title>Kubernetes NetworkPolicies — default-deny done right</title>
      <dc:creator>secopslog</dc:creator>
      <pubDate>Sat, 11 Jul 2026 08:59:17 +0000</pubDate>
      <link>https://dev.to/secopsloghq/kubernetes-networkpolicies-default-deny-done-right-293d</link>
      <guid>https://dev.to/secopsloghq/kubernetes-networkpolicies-default-deny-done-right-293d</guid>
      <description>&lt;p&gt;&lt;em&gt;Default-deny and per-consumer allowlists.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;In plain terms:&lt;/strong&gt; By default every pod can talk to every other pod — one big open-plan office. A NetworkPolicy adds doors and a guest list so only approved conversations happen. Just remember to leave the phone line (DNS) open.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The cluster network is flat and open by default — any pod can reach any other pod. NetworkPolicies are how you segment it, and adopting a default-deny posture with explicit allowlists is the single most effective control against lateral movement inside a cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Default-deny and allowlists
&lt;/h2&gt;

&lt;p&gt;A NetworkPolicy selects pods (by label) and specifies the ingress and egress traffic they are allowed — and crucially, the moment any policy selects a pod, that pod switches from allow-all to deny-all except what the policies permit. So the foundational move is a default-deny policy: one that selects all pods in a namespace and allows nothing, flipping the namespace from open to closed. Then you add targeted allow policies for the flows that must exist — the frontend may reach the API on port 8080, the API may reach the database on 5432, everything reaches DNS. This is the same least-privilege model as RBAC applied to the network: start closed, open only what is needed, and identify peers by label (podSelector/namespaceSelector) so rules follow workloads rather than fragile IPs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1. Default-deny ALL ingress + egress in the namespace (flip it closed):&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;default-deny&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;payments&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{},&lt;/span&gt; &lt;span class="nv"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Ingress&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Egress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;# 2. Allow only what is needed (and DON'T forget DNS egress):&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;api-from-frontend&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;payments&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;api&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;frontend&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}],&lt;/span&gt;
             &lt;span class="nv"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The two things that trip people up
&lt;/h2&gt;

&lt;p&gt;Two caveats decide whether NetworkPolicies actually work. First, they are enforced by the CNI, not by Kubernetes itself — on a plugin that does not support them (basic Flannel) your policies are accepted by the API but silently unenforced, so you must confirm your CNI (Calico, Cilium) enforces them and test that a default-deny actually blocks traffic. Second, a default-deny egress policy blocks DNS unless you explicitly allow it — pods query CoreDNS in kube-system on port 53, and if your policy forgets that, name resolution fails for every affected pod and their dependencies appear down, a very common self-inflicted breakage. So the recipe is: verify the CNI enforces policy, apply default-deny, then add allows including DNS egress to kube-system, and test. Done right, NetworkPolicies box a compromised pod into the tiny set of flows you granted — the difference between a foothold that can scan and reach the whole cluster and one that can talk only to the two services it legitimately needs. This is the network complement to RBAC and security contexts, and it is the highest-leverage control against lateral movement.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;A default-deny that forgets DNS breaks every pod it covers:&lt;/strong&gt; A default-deny egress policy blocks pods from reaching CoreDNS unless you explicitly allow DNS (port 53 to kube-system), so name resolution fails and dependencies appear down across the namespace. Always add a DNS egress allow when applying default-deny — and remember policies only work if your CNI enforces them, so verify a default-deny actually blocks traffic before relying on it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;This lesson is part of the free *&lt;/em&gt;&lt;a href="https://secopslog.com/courses/k8s-admin/" rel="noopener noreferrer"&gt;Kubernetes administration&lt;/a&gt;** course at &lt;strong&gt;&lt;a href="https://secopslog.com" rel="noopener noreferrer"&gt;SecOpsLog&lt;/a&gt;&lt;/strong&gt; — hands-on, command-first DevSecOps tutorials. &lt;a href="https://secopslog.com/courses/k8s-admin/netpol/" rel="noopener noreferrer"&gt;Read the original with the full interactive version →&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>infrastructure</category>
      <category>kubernetes</category>
      <category>networking</category>
      <category>security</category>
    </item>
    <item>
      <title>Vault auth methods — OIDC for humans, Kubernetes for workloads</title>
      <dc:creator>secopslog</dc:creator>
      <pubDate>Sat, 11 Jul 2026 08:47:00 +0000</pubDate>
      <link>https://dev.to/secopsloghq/vault-auth-methods-oidc-for-humans-kubernetes-for-workloads-iok</link>
      <guid>https://dev.to/secopsloghq/vault-auth-methods-oidc-for-humans-kubernetes-for-workloads-iok</guid>
      <description>&lt;p&gt;&lt;em&gt;Humans via SSO, workloads via service accounts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Think of Vault as a secured building with two entrances. Humans walk in through the front lobby, where the badge reader is wired straight into your corporate identity provider — there is no separate Vault password to remember or leak, just the SSO login your engineers already have. Workloads use the loading dock around back, where the only ID that counts is the service account token the pod was born with. Same building, two very different doors, and each needs a different lock. Here we wire up both: OIDC for people, Kubernetes auth for machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The front door: OIDC for humans
&lt;/h2&gt;

&lt;p&gt;OIDC auth lets Vault delegate the question "who are you?" to an identity provider you already trust — Okta, Entra ID, Google, Keycloak. Vault never sees a password. It redirects the browser to the IdP, receives a signed ID token back, and reads claims out of it. You bind a role to the IdP's client ID and tell Vault which claim uniquely identifies the person (user_claim) and which claim carries their group membership (groups_claim). The allowed_redirect_uris must list both the CLI's localhost callback and your Vault UI address, exactly — scheme, port, and path included. Get one character wrong and the IdP refuses the handoff before Vault ever sees a token. Because the Vault token that comes out is short-lived, the human re-authenticates through SSO when it expires, so disabling their account upstream cuts off Vault on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Turn on the OIDC auth method&lt;/span&gt;
vault auth &lt;span class="nb"&gt;enable &lt;/span&gt;oidc

&lt;span class="c"&gt;# Point Vault at your identity provider&lt;/span&gt;
vault write auth/oidc/config &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;oidc_discovery_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://login.example.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;oidc_client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OIDC_CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;oidc_client_secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OIDC_CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;default_role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"engineer"&lt;/span&gt;

&lt;span class="c"&gt;# A role: which claims to trust, where to redirect, what token to mint&lt;/span&gt;
vault write auth/oidc/role/engineer &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;user_claim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sub"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;groups_claim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"groups"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;oidc_scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"openid,profile,groups"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;bound_audiences&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OIDC_CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;allowed_redirect_uris&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:8250/oidc/callback"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;allowed_redirect_uris&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://vault.example.com/ui/vault/auth/oidc/oidc/callback"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;token_policies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"engineer"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;token_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1h

&lt;span class="c"&gt;# Engineers now log in with:  vault login -method=oidc role=engineer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Turning SSO groups into Vault policy
&lt;/h2&gt;

&lt;p&gt;Granting policy to individual users does not scale and drifts the moment someone changes teams. Instead, let group membership in your IdP drive Vault authorization. When a user logs in through OIDC, Vault reads the groups_claim and looks for a matching group alias. You pre-create an external identity group, attach the policies you want it to carry, and map the IdP's group name to it through a group alias keyed on the OIDC mount accessor. Now a person added to platform-eng in Okta inherits the platform policy on their very next login, and removing them there revokes it — no Vault change, no ticket, no stale grant left behind. Membership lives in exactly one system, and Vault follows it. The alias name must match the string your IdP actually emits in the groups claim, which is often a group ID rather than a display name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Grab the accessor of the OIDC mount&lt;/span&gt;
&lt;span class="nv"&gt;ACCESSOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;vault auth list &lt;span class="nt"&gt;-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'."oidc/".accessor'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# External group that carries the policy&lt;/span&gt;
vault write identity/group &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"platform-eng"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"external"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;policies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"platform"&lt;/span&gt;

&lt;span class="nv"&gt;GROUP_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;vault &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;identity/group/name/platform-eng&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Alias ties the IdP's group name to the Vault group&lt;/span&gt;
vault write identity/group-alias &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"platform-eng"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;mount_accessor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACCESSOR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;canonical_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The loading dock: Kubernetes auth for workloads
&lt;/h2&gt;

&lt;p&gt;Machines cannot do a browser redirect, so workloads authenticate with the one credential Kubernetes already hands every pod: its projected service account token. Vault takes that JWT, calls the cluster's TokenReview API to confirm it is genuine and unexpired, and — if the pod's service account name and namespace match a role's bindings — issues a Vault token in return. There is no secret to distribute and no static credential to rotate; the identity is the pod's own. Since Kubernetes 1.21 these tokens are time-bound and audience-scoped, so one scraped from a pod cannot be replayed forever or against a different audience. Bind each role narrowly to a service account name and namespace, so only the webapp pods in prod can ever assume the webapp policy — a compromised pod in another namespace gets nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vault auth &lt;span class="nb"&gt;enable &lt;/span&gt;kubernetes

&lt;span class="c"&gt;# In-cluster: you supply the host; Vault defaults to its own pod's&lt;/span&gt;
&lt;span class="c"&gt;# service account token and the local cluster CA to reach the API&lt;/span&gt;
vault write auth/kubernetes/config &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;kubernetes_host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://kubernetes.default.svc:443"&lt;/span&gt;

&lt;span class="c"&gt;# Only the 'webapp' SA in 'prod' may assume this role&lt;/span&gt;
vault write auth/kubernetes/role/webapp &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;bound_service_account_names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"webapp"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;bound_service_account_namespaces&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;token_policies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"webapp"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;token_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1h

&lt;span class="c"&gt;# From inside the pod, exchange the SA token for a Vault token&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /var/run/secrets/kubernetes.io/serviceaccount/token&lt;span class="si"&gt;)&lt;/span&gt;
vault write auth/kubernetes/login &lt;span class="nv"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;webapp &lt;span class="nv"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;TokenReview returns 403 and every pod login fails:&lt;/strong&gt; With no token_reviewer_jwt on the mount, Vault does not review a pod's token with that same token — when Vault runs inside the cluster it uses its OWN pod's service account token to call the TokenReview API. So it is Vault's service account, not each application's, that must be bound to the system:auth-delegator ClusterRole. The Vault Helm chart wires this binding for the server service account by default, so in-cluster logins usually work out of the box — until someone overrides the chart's service account, or runs Vault outside the cluster. Out-of-cluster (or with disable_local_ca_jwt set) and still no reviewer JWT, Vault falls back to reviewing each login with the caller's own token, and now every authenticating service account needs the delegator — a brittle pattern. The durable fix is to set an explicit token_reviewer_jwt (a dedicated reviewer service account) so Vault always reviews with one privileged identity. Either way Kubernetes answers 403 as a misleading "permission denied" even when your role bindings are perfect, so check the RBAC before you burn an afternoon re-reading correct-looking bindings.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;This lesson is part of the free *&lt;/em&gt;&lt;a href="https://secopslog.com/courses/vault-prod/" rel="noopener noreferrer"&gt;Vault from dev to production&lt;/a&gt;** course at &lt;strong&gt;&lt;a href="https://secopslog.com" rel="noopener noreferrer"&gt;SecOpsLog&lt;/a&gt;&lt;/strong&gt; — hands-on, command-first DevSecOps tutorials. &lt;a href="https://secopslog.com/courses/vault-prod/lesson-2/" rel="noopener noreferrer"&gt;Read the original with the full interactive version →&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>devops</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Helm template functions, pipelines &amp; named templates</title>
      <dc:creator>secopslog</dc:creator>
      <pubDate>Sat, 11 Jul 2026 08:42:42 +0000</pubDate>
      <link>https://dev.to/secopsloghq/helm-template-functions-pipelines-named-templates-okp</link>
      <guid>https://dev.to/secopsloghq/helm-template-functions-pipelines-named-templates-okp</guid>
      <description>&lt;p&gt;&lt;em&gt;DRY templates with helpers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If the templating engine is the language, functions and named templates are its standard library plus the reusable helpers you write yourself. Think of a pipeline the way a shell pipes commands: a value flows left to right, and each function reshapes it before handing it on. Named templates are the functions you author — define a block of YAML once, give it a name, then call it from every manifest so a change lands in one place instead of twenty. The goal is staying DRY: composing built-in functions into pipelines, and factoring shared fragments into reusable helpers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Functions and pipelines
&lt;/h2&gt;

&lt;p&gt;Helm ships the Go template built-ins plus the entire Sprig library — string, list, math, encoding and date helpers. You call them two ways. Nested, reading right to left: quote (upper .Values.name). Or piped, reading left to right, where the pipe feeds the previous result in as the last argument of the next function. Pipelines are almost always clearer. The workhorses are default to supply a fallback, quote to wrap a value safely, trunc and trimSuffix to respect Kubernetes' 63-character name limit, and the conversion pair toYaml plus nindent for embedding a whole values block with correct indentation. Reach for printf when you need to build a composite string. Note that Helm deliberately disables a few Sprig functions such as env and expandenv, so a chart cannot read the machine it happens to render on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nested calls read right-to-left&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;quote (default "app" .Values.name)&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;

&lt;span class="c1"&gt;# the same thing as a pipeline, reading left-to-right&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Values.name | default "app" | quote&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;

&lt;span class="c1"&gt;# respect the 63-char name limit, then embed a whole block&lt;/span&gt;
&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Values.appName | lower | trunc 63 | trimSuffix "-"&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- .Values.settings | toYaml | nindent 2&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Named templates with define and include
&lt;/h2&gt;

&lt;p&gt;A named template is a fragment declared with define and rendered with include. By convention they live in templates/_helpers.tpl — files whose names start with an underscore are never rendered into Kubernetes objects themselves, they only supply helpers. Template names are global across a chart and all its subcharts, so the name is a dotted string namespaced to your chart, like myapp.labels, to keep subcharts from colliding. helm create scaffolds this pattern for you: a fullname helper that stitches the release name and chart name together, truncates to 63 characters and trims a trailing dash, plus shared label and selector blocks. Define the standard labels once and every Deployment, Service and ServiceAccount stays consistent for free. The dash-trimmed delimiters,{{ and }}, matter inside helpers because they strip the surrounding whitespace so the emitted YAML lines up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;templates/_helpers.tpl&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;/* A release-scoped name&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;safe for Kubernetes */&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- define "myapp.fullname" -&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- $name&lt;/span&gt; &lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;= default .Chart.Name .Values.nameOverride -&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- end -&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;

&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;/* Standard labels reused by every object */&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- define "myapp.labels" -&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Chart.Name&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="na"&gt;app.kubernetes.io/instance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Release.Name&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="na"&gt;helm.sh/chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Chart.Name&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;-{{ .Chart.Version }}&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- end -&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scope, and why include beats template
&lt;/h2&gt;

&lt;p&gt;Two things trip everyone up: how to indent the output, and what dot means inside the helper. Always call helpers with include, not the template action. include is a Helm-specific function that returns the rendered text as a string, so you can pipe it into nindent. template prints straight to output and cannot be piped, so you lose all control of indentation. The final argument you pass is the scope the helper sees as dot. Passing a plain dot hands over the current context; passing a dollar sign hands over the root context — the top-level scope that always holds .Values, .Release and .Chart. Inside a range, dot is the loop item, so to give a helper both the item and the root you package them into a dict and read them back by key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# in a template that loops, bundle root ($) and item into one dict&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- range .Values.services&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
  &lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- include "myapp.serviceLabels" (dict "top" $ "svc" .) | nindent 4&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- end&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;

&lt;span class="c1"&gt;# the helper reads the values back by key&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- define "myapp.serviceLabels" -&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="na"&gt;app.kubernetes.io/instance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.top.Release.Name&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.svc.name&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- end -&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;include is pipeable; template is not:&lt;/strong&gt; Writing {{ template "myapp.labels" . | nindent 4 }} is a parse error — template is a bare action, not a function, so it cannot appear in a pipeline. Helm added include precisely to fix this: it does the same lookup but returns a string you can pipe into nindent, indent or toYaml. Prefer nindent N over a leading newline plus indent N, because nindent prepends the newline for you — which is exactly why you must never stack your own leading newline on top of it, or you get a blank line and shifted indentation. nindent sets the block's indentation absolutely and re-indents every line it emits, so the normal, correct pattern is to put the include right after a mapping key (or alone on a dash-trimmed line) and let it own the whitespace. What you must not do is append anything after the include on the same line: its output ends without a trailing newline, so trailing text lands mis-indented — YAML that can lint clean locally yet fail once the manifest reaches the cluster.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;This lesson is part of the free *&lt;/em&gt;&lt;a href="https://secopslog.com/courses/helm/" rel="noopener noreferrer"&gt;Helm&lt;/a&gt;** course at &lt;strong&gt;&lt;a href="https://secopslog.com" rel="noopener noreferrer"&gt;SecOpsLog&lt;/a&gt;&lt;/strong&gt; — hands-on, command-first DevSecOps tutorials. &lt;a href="https://secopslog.com/courses/helm/hl-functions/" rel="noopener noreferrer"&gt;Read the original with the full interactive version →&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>devsecops</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kustomize bases &amp; overlays — one base, every environment</title>
      <dc:creator>secopslog</dc:creator>
      <pubDate>Sat, 11 Jul 2026 07:28:00 +0000</pubDate>
      <link>https://dev.to/secopsloghq/kustomize-bases-overlays-one-base-every-environment-2gim</link>
      <guid>https://dev.to/secopsloghq/kustomize-bases-overlays-one-base-every-environment-2gim</guid>
      <description>&lt;p&gt;&lt;em&gt;dev, staging, prod from one base.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Kustomize base is a master recipe; overlays are the diners' tweaks. You cook one dish — a Deployment, a Service, a ConfigMap — that captures everything your app needs regardless of where it runs. Then, for each environment, you write a thin overlay that says "start from that recipe, but make dev quieter, staging half-sized, and prod cranked to eleven." Nothing is copied. The base stays the single source of truth, and every environment is expressed only as its difference from it. That difference is usually tiny — a handful of lines — which is exactly the point: the smaller the overlay, the less there is to drift, forget, or get wrong when you promote a change from dev to prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  The base: your common denominator
&lt;/h2&gt;

&lt;p&gt;A base is a complete, self-contained, deployable set of resources plus its own kustomization.yaml. That last part trips people up: a base is not a loose pile of YAML — it is itself a valid kustomization that lists the files it owns. Build it on its own and you get a working manifest for a generic, un-specialized deployment. Keep the base to the common denominator: the things that are true in every environment. The moment you find yourself adding a value that is only right for one environment, that value belongs in an overlay, not here. A useful test: could you hand this base to a teammate on a different cluster and have it apply cleanly? If it only works because dev's namespace happens to exist, it isn't really a base.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/base/kustomization.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;

&lt;span class="c1"&gt;# A base is a real kustomization: it names the raw files it owns.&lt;/span&gt;
&lt;span class="c1"&gt;# These are the resources EVERY environment shares, unspecialized.&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;deployment.yaml&lt;/span&gt;   &lt;span class="c1"&gt;# generic Deployment: image, ports, 1 replica&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;service.yaml&lt;/span&gt;      &lt;span class="c1"&gt;# generic ClusterIP Service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Overlays: one per environment
&lt;/h2&gt;

&lt;p&gt;An overlay is another kustomization.yaml that names the base in its &lt;code&gt;resources:&lt;/code&gt; list — as a directory path, not a file. Below that reference sits only what makes this environment different: a distinct namespace, a name prefix so prod and dev objects never collide in a shared cluster, a replica count tuned to load. Kustomize reads the base, then layers your overlay changes on top, and emits the merged result. The overlay never edits the base's files; it declares deltas. This is why a three-line change to replica count in prod leaves dev and staging completely untouched — they don't share the overlay, only the base. Overlays can also stack — an overlay can itself serve as the base for another — but for dev/staging/prod a single flat base with three sibling overlays is the layout you want, and the one that stays legible a year later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/overlays/prod/kustomization.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;

&lt;span class="c1"&gt;# Point at the base by RELATIVE DIRECTORY path (not a file).&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;../../base&lt;/span&gt;

&lt;span class="c1"&gt;# Everything below is only what makes prod differ from the base:&lt;/span&gt;
&lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-prod&lt;/span&gt;   &lt;span class="c1"&gt;# isolate prod objects&lt;/span&gt;
&lt;span class="na"&gt;namePrefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod-&lt;/span&gt;       &lt;span class="c1"&gt;# avoid name collisions in shared clusters&lt;/span&gt;

&lt;span class="c1"&gt;# Bump replicas for production load.&lt;/span&gt;
&lt;span class="c1"&gt;# Match by the ORIGINAL base name (myapp); namePrefix is applied&lt;/span&gt;
&lt;span class="c1"&gt;# separately, so this still targets the renamed prod-myapp object.&lt;/span&gt;
&lt;span class="c1"&gt;# (Deeper surgical edits use strategic-merge or JSON6902 patches.)&lt;/span&gt;
&lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
    &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Building each environment
&lt;/h2&gt;

&lt;p&gt;Each environment is just a different directory to point the build at. &lt;code&gt;kustomize build overlays/prod&lt;/code&gt; renders the fully-specialized prod manifests to stdout; &lt;code&gt;kubectl apply -k overlays/prod&lt;/code&gt; does the same and applies them in one step. The key idea for overlays is that the same command, aimed at a different folder, produces a different environment from the identical base — no branching, no templating, no copy-paste drift between dev, staging, and prod. Diff two builds and the output is the set of deltas you declared, applied across the base.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;myapp/
├── base/
│   ├── kustomization.yaml
│   ├── deployment.yaml
│   └── service.yaml
└── overlays/
    ├── dev/kustomization.yaml
    ├── staging/kustomization.yaml
    └── prod/kustomization.yaml

&lt;span class="c"&gt;# Same command, different folder = different environment:&lt;/span&gt;
kustomize build overlays/dev
kustomize build overlays/prod

&lt;span class="c"&gt;# Or render + apply in one shot (-k runs kustomize on the dir):&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; overlays/prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Paths resolve from the overlay, and 'bases:' is dead:&lt;/strong&gt; Two things bite here. First, the old &lt;code&gt;bases:&lt;/code&gt; field is deprecated — put base directories in the same &lt;code&gt;resources:&lt;/code&gt; list as your files; kustomize treats a directory entry as a base and a file entry as a resource, so there is no separate field anymore. (A leftover &lt;code&gt;bases:&lt;/code&gt; still builds but prints a deprecation warning; run &lt;code&gt;kustomize edit fix&lt;/code&gt; to migrate it.) Second, paths in &lt;code&gt;resources:&lt;/code&gt; are resolved relative to the overlay's own kustomization.yaml, not your shell's working directory — &lt;code&gt;../../base&lt;/code&gt; climbs up from overlays/prod/ to myapp/base/, and it will resolve the same no matter where you run the command from. By default, though, a kustomization cannot load a file from outside its own root: reference an individual YAML that sits above or beside your tree and the build fails with a security/load-restriction error. You can force it with &lt;code&gt;kustomize build --load-restrictor LoadRestrictionsNone&lt;/code&gt;, but reaching for that flag is almost always a sign your layout is wrong, not that you need the escape hatch — restructure so the shared files live inside the tree instead.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;This lesson is part of the free *&lt;/em&gt;&lt;a href="https://secopslog.com/courses/kustomize/" rel="noopener noreferrer"&gt;Kustomize&lt;/a&gt;** course at &lt;strong&gt;&lt;a href="https://secopslog.com" rel="noopener noreferrer"&gt;SecOpsLog&lt;/a&gt;&lt;/strong&gt; — hands-on, command-first DevSecOps tutorials. &lt;a href="https://secopslog.com/courses/kustomize/ku-overlays/" rel="noopener noreferrer"&gt;Read the original with the full interactive version →&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>devsecops</category>
      <category>devops</category>
    </item>
    <item>
      <title>Argo CD sync policies &amp; self-heal, explained</title>
      <dc:creator>secopslog</dc:creator>
      <pubDate>Sat, 11 Jul 2026 07:25:51 +0000</pubDate>
      <link>https://dev.to/secopsloghq/argo-cd-sync-policies-self-heal-explained-3eg5</link>
      <guid>https://dev.to/secopsloghq/argo-cd-sync-policies-self-heal-explained-3eg5</guid>
      <description>&lt;p&gt;&lt;em&gt;Auto-sync, prune, self-heal.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A syncPolicy is the thermostat setting for an Argo CD Application. Argo CD continuously compares Git against the live cluster to detect drift; the sync policy decides what it does about it — whether it waits for a human to press sync, or acts on its own. Left at the default, an OutOfSync app just sits there flagged until someone runs argocd app sync. Turn on automation and that same detection becomes continuous delivery: every commit to the tracked revision rolls out by itself, and the cluster is dragged back toward Git whenever it strays. Three switches shape how far that goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automated, prune, and self-heal
&lt;/h2&gt;

&lt;p&gt;spec.syncPolicy.automated is the master switch: with it set, Argo CD syncs automatically after any reconcile where Git and the cluster differ, with no manual step. Inside it, two booleans decide how aggressive that is. prune governs deletions — by default an automated sync only creates and updates, so a manifest you delete from Git leaves its live object orphaned in the cluster until you set prune: true, at which point a removal in Git becomes a deletion in the cluster. selfHeal governs the other direction: with it off, drift you introduce out-of-band — an edited replica count, a hand-patched env var — is reported but left alone; with it on, Argo CD overwrites the live object back to what Git declares on the next pass. automated alone tracks Git forward; add prune and selfHeal and the cluster becomes a strict mirror of the repo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;        &lt;span class="c1"&gt;# delete resources removed from Git&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;     &lt;span class="c1"&gt;# revert out-of-band drift back to Git&lt;/span&gt;
      &lt;span class="na"&gt;allowEmpty&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# refuse to prune everything (the default)&lt;/span&gt;
&lt;span class="c1"&gt;# equivalent via the CLI:&lt;/span&gt;
&lt;span class="c1"&gt;# argocd app set payments-api --sync-policy automated --auto-prune --self-heal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Sync options and retry
&lt;/h2&gt;

&lt;p&gt;Beyond the three switches, syncOptions tune the mechanics of each sync. CreateNamespace=true makes Argo CD create the destination namespace instead of failing when it is absent. ApplyOutOfSyncOnly=true skips resources that already match, so large apps reconcile faster. PruneLast=true defers deletions until everything else is healthy, so a rename removes the old object only after its replacement is up. PrunePropagationPolicy=foreground makes a deletion block on its dependents rather than orphaning them. And retry decides what happens when a sync fails: limit caps the attempts while backoff grows the wait between them, so a transient failure — an image not yet pushed, a webhook briefly down — is retried with increasing delay instead of either giving up or hammering the API. These options apply to manual and automated syncs alike; they describe how a sync runs, not when.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CreateNamespace=true&lt;/span&gt;       &lt;span class="c1"&gt;# create the namespace if it is missing&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ApplyOutOfSyncOnly=true&lt;/span&gt;    &lt;span class="c1"&gt;# skip resources already in sync&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PruneLast=true&lt;/span&gt;             &lt;span class="c1"&gt;# delete only after the rest is healthy&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PrunePropagationPolicy=foreground&lt;/span&gt;
    &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5s&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;factor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;maxDuration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;3m&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A posture for each environment
&lt;/h2&gt;

&lt;p&gt;The right policy is not one setting but a spectrum you choose per environment. Dev and preview environments usually run fully automated with prune and selfHeal for hands-off, always-fresh clusters — mistakes there are cheap and self-correcting. Production often runs automated without selfHeal, or manual sync entirely, so a human still confirms the rollout and can pause it mid-incident; the review gate then lives in branch protection on the Git repo rather than at deploy time, which is where GitOps wants it. Keep allowEmpty at its default of false everywhere: it stops an automated sync from pruning every resource when a bad Helm render or an empty path briefly makes Git look like it declares nothing — the difference between a harmless no-op and deleting your entire app. Individual resources can also opt out of prune with a sync-options annotation, which is worth doing for anything stateful.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# prod: automated apply, but no self-heal — a human still owns live drift&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;argocd app &lt;span class="nb"&gt;set &lt;/span&gt;payments-api &lt;span class="nt"&gt;--sync-policy&lt;/span&gt; automated &lt;span class="nt"&gt;--auto-prune&lt;/span&gt;

&lt;span class="c"&gt;# revert prod to fully manual (the default when there is no automated block)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;argocd app &lt;span class="nb"&gt;set &lt;/span&gt;payments-api &lt;span class="nt"&gt;--sync-policy&lt;/span&gt; none

&lt;span class="c"&gt;# shield one resource from prune, in its own manifest:&lt;/span&gt;
&lt;span class="c"&gt;#   metadata:&lt;/span&gt;
&lt;span class="c"&gt;#     annotations:&lt;/span&gt;
&lt;span class="c"&gt;#       argocd.argoproj.io/sync-options: Prune=false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Self-heal fights any controller that owns the same field:&lt;/strong&gt; selfHeal rewrites the live object to match Git on every pass, so if another controller legitimately writes a field Argo CD manages, the two fight forever. The classic case is a HorizontalPodAutoscaler adjusting spec.replicas while Argo CD keeps resetting it to the number baked into the manifest — the app flaps between Synced and OutOfSync and pods churn on every reconcile. The fix is not to switch self-heal off but to stop Argo CD from diffing that one field: add a spec.ignoreDifferences entry targeting /spec/replicas so Git owns the manifest and the HPA owns its one field (or, cleaner, drop replicas from the manifest entirely so there is nothing to reconcile). Argo CD ships no default ignore for this — you configure it yourself. Before enabling self-heal cluster-wide, audit what else mutates your resources — admission webhooks, service meshes, cert-manager injecting CA bundles — because each of those is a candidate for the same tug-of-war.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;This lesson is part of the free *&lt;/em&gt;&lt;a href="https://secopslog.com/courses/argocd/" rel="noopener noreferrer"&gt;Argo CD&lt;/a&gt;** course at &lt;strong&gt;&lt;a href="https://secopslog.com" rel="noopener noreferrer"&gt;SecOpsLog&lt;/a&gt;&lt;/strong&gt; — hands-on, command-first DevSecOps tutorials. &lt;a href="https://secopslog.com/courses/argocd/ag-syncpolicy/" rel="noopener noreferrer"&gt;Read the original with the full interactive version →&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>devsecops</category>
      <category>devops</category>
    </item>
    <item>
      <title>Migrating from Terraform to OpenTofu, safely</title>
      <dc:creator>secopslog</dc:creator>
      <pubDate>Sat, 11 Jul 2026 07:22:44 +0000</pubDate>
      <link>https://dev.to/secopsloghq/migrating-from-terraform-to-opentofu-safely-4ob4</link>
      <guid>https://dev.to/secopsloghq/migrating-from-terraform-to-opentofu-safely-4ob4</guid>
      <description>&lt;p&gt;&lt;em&gt;Move an existing project, safely.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Migrating from Terraform to OpenTofu is less an engine transplant than a badge swap. OpenTofu began as a line-for-line fork of Terraform 1.5, so your &lt;code&gt;.tf&lt;/code&gt; files and your state mean exactly the same thing to &lt;code&gt;tofu&lt;/code&gt; as they did to &lt;code&gt;terraform&lt;/code&gt;; your &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; is understood in the identical format, the one file &lt;code&gt;tofu init&lt;/code&gt; will regenerate as it re-sources provider hashes from OpenTofu's registry. The migration is mostly deleting one binary from your habits and pointing your muscle memory at another. The care goes not into rewriting code but into proving that nothing changed: the acceptance test for a good migration is that the very first &lt;code&gt;tofu plan&lt;/code&gt; against your existing state comes back empty. Everything below is about earning that empty plan and having a way back if you don't get it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back up state before you touch anything
&lt;/h2&gt;

&lt;p&gt;Even a drop-in replacement deserves a rollback plan, because state is the only irreplaceable artifact in the project. Your &lt;code&gt;.tf&lt;/code&gt; files live in git and can be re-checked-out at will, but the state file is the map from that code to the real resources it owns; lose it or corrupt it and you own orphaned infrastructure with no way to plan against it. So copy the state and the dependency lock file before you run a single &lt;code&gt;tofu&lt;/code&gt; command. If you're on a remote backend, pull a local snapshot with the terraform binary while it's still installed, and note the exact Terraform version that last wrote the state, because that version dictates whether OpenTofu can read it cleanly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Local backend: copy the state and the provider lock&lt;/span&gt;
&lt;span class="nb"&gt;cp &lt;/span&gt;terraform.tfstate terraform.tfstate.backup.pre-tofu
&lt;span class="nb"&gt;cp&lt;/span&gt; .terraform.lock.hcl .terraform.lock.hcl.backup

&lt;span class="c"&gt;# Remote backend: snapshot state to a local file first&lt;/span&gt;
terraform state pull &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; pre-tofu.tfstate

&lt;span class="c"&gt;# Record which Terraform version wrote this state (matters below)&lt;/span&gt;
terraform version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Swap terraform for tofu and confirm an empty plan
&lt;/h2&gt;

&lt;p&gt;With the &lt;code&gt;tofu&lt;/code&gt; CLI installed, the operative commands are the same three you already run. &lt;code&gt;tofu init&lt;/code&gt; re-reads your backend and re-resolves providers (now from the OpenTofu registry) and rewrites &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; with OpenTofu-sourced hashes for the same provider versions. Then &lt;code&gt;tofu plan&lt;/code&gt; is your verdict. A migration that worked prints &lt;code&gt;No changes. Your infrastructure matches the configuration.&lt;/code&gt; If tofu instead wants to add, change, or destroy anything, stop and read the diff: a non-empty plan almost never means your infrastructure drifted overnight. It means a provider version or source address resolved differently, so fix the resolution rather than applying the plan.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu init            &lt;span class="c"&gt;# re-resolves providers, rewrites the lock with OpenTofu hashes&lt;/span&gt;
tofu plan            &lt;span class="c"&gt;# the acceptance test -- expect an empty diff&lt;/span&gt;

&lt;span class="c"&gt;# Expected tail of a clean migration:&lt;/span&gt;
&lt;span class="c"&gt;# No changes. Your infrastructure matches the configuration.&lt;/span&gt;

&lt;span class="c"&gt;# The regenerated .terraform.lock.hcl is a repo file, not state --&lt;/span&gt;
&lt;span class="c"&gt;# commit it to version control so CI resolves identical hashes:&lt;/span&gt;
git add .terraform.lock.hcl

&lt;span class="c"&gt;# A clean, empty-plan cutover needs no apply. Your next real&lt;/span&gt;
&lt;span class="c"&gt;# infrastructure change is the first 'tofu apply' you run.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What changes — and what reassuringly doesn't
&lt;/h2&gt;

&lt;p&gt;Very little of your configuration is actually Terraform-specific, and the one construct people fear they must rip out survives the fork. OpenTofu kept the &lt;code&gt;terraform {}&lt;/code&gt; settings block name, so you rename nothing, and it still honours &lt;code&gt;TF_VAR_&lt;/code&gt;, &lt;code&gt;TF_WORKSPACE&lt;/code&gt;, &lt;code&gt;TF_CLI_ARGS&lt;/code&gt;, and every other &lt;code&gt;TF_&lt;/code&gt;-prefixed environment variable, so none of your CI variables change (OpenTofu also adds &lt;code&gt;TOFU_&lt;/code&gt;-prefixed equivalents that win when both are set). Even the &lt;code&gt;cloud {}&lt;/code&gt; block lives on: OpenTofu retains it, and its cloud backend documents connecting to HCP Terraform (formerly Terraform Cloud) or Terraform Enterprise, so you are not forced to delete it just to run &lt;code&gt;tofu&lt;/code&gt;. What genuinely does not cross the fork are HCP Terraform's proprietary features — Sentinel policy sets, run tasks, drift detection, no-code modules — which are HashiCorp-only and have no OpenTofu equivalent. So the real decision is platform, not syntax: keep the cloud block if you're staying on HCP or Terraform Enterprise, or, if you're leaving that platform entirely, swap it for a standard backend such as S3 or GCS. Only the CLI you invoke is guaranteed to change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The cloud block is NOT Terraform-only -- OpenTofu keeps it, and its&lt;/span&gt;
&lt;span class="c1"&gt;# cloud backend can target HCP Terraform or Terraform Enterprise:&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cloud&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;organization&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"acme"&lt;/span&gt;
    &lt;span class="nx"&gt;workspaces&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Only if you are LEAVING that platform entirely do you swap it for a&lt;/span&gt;
&lt;span class="c1"&gt;# standard backend:&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"acme-tofu-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Match your OpenTofu version to the Terraform you're leaving:&lt;/strong&gt; State is broadly forward-compatible: OpenTofu 1.6 forked from Terraform 1.5/1.6, and later OpenTofu releases deliberately track Terraform's state format, so &lt;code&gt;tofu&lt;/code&gt; reads state written by Terraform 1.5 through 1.9 without a format wall. Two real hazards remain. First, the version marker: state records the version that last wrote it, and a tool can refuse state stamped by a version newer than itself with a 'state snapshot was created by … which is newer than current' error — so if a teammate already ran &lt;code&gt;terraform apply&lt;/code&gt; with a version ahead of the OpenTofu you install, install an OpenTofu release at least as new before you migrate. Second, config features: a project using a Terraform-only feature newer than your OpenTofu (for example a 1.8+ provider-defined function) can fail &lt;code&gt;tofu init&lt;/code&gt; or &lt;code&gt;tofu plan&lt;/code&gt; even when the state itself reads fine — again cured by installing a recent-enough OpenTofu and migrating from a version you control rather than one someone has already leapfrogged. Never hand-edit the &lt;code&gt;version&lt;/code&gt; or &lt;code&gt;terraform_version&lt;/code&gt; fields to force a load; you'll risk corrupting resource decoding and losing the state you were trying to save. Finally, treat the cutover as one-way: add a guard in CI so nobody keeps running &lt;code&gt;terraform apply&lt;/code&gt; against tofu-managed state and quietly stamps the marker back the other way.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;This lesson is part of the free *&lt;/em&gt;&lt;a href="https://secopslog.com/courses/opentofu/" rel="noopener noreferrer"&gt;OpenTofu&lt;/a&gt;** course at &lt;strong&gt;&lt;a href="https://secopslog.com" rel="noopener noreferrer"&gt;SecOpsLog&lt;/a&gt;&lt;/strong&gt; — hands-on, command-first DevSecOps tutorials. &lt;a href="https://secopslog.com/courses/opentofu/ot-migrate/" rel="noopener noreferrer"&gt;Read the original with the full interactive version →&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>devsecops</category>
      <category>security</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
