<?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: Core Solutions</title>
    <description>The latest articles on DEV Community by Core Solutions (coresolutions).</description>
    <link>https://dev.to/coresolutions</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%2F14548%2Fb59cdcc2-4bee-4a01-b9a1-4fbf07d6253e.jpg</url>
      <title>DEV Community: Core Solutions</title>
      <link>https://dev.to/coresolutions</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/coresolutions"/>
    <language>en</language>
    <item>
      <title>GitOps with Helm for small teams: what</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Thu, 24 Sep 2026 17:34:34 +0000</pubDate>
      <link>https://dev.to/coresolutions/gitops-with-helm-for-small-teams-what-59cc</link>
      <guid>https://dev.to/coresolutions/gitops-with-helm-for-small-teams-what-59cc</guid>
      <description>&lt;p&gt;Most small teams arrive at GitOps from the same place. Every service might already have a Helm chart, but production is whatever someone last ran &lt;code&gt;helm upgrade&lt;/code&gt; with from their laptop, plus the &lt;code&gt;--set replicaCount=4&lt;/code&gt; somebody added during last month's incident, plus a values file that only exists in one engineer's home directory. Then they go looking for help and find a wall of machinery: app-of-apps layouts, promotion pipelines, multi-cluster generators, progressive delivery. If you are a team of three trying to stop deploys happening from somebody's laptop, that is not a realistic starting point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For small teams, the part of GitOps that pays off immediately is the reconciliation loop. Most of the machinery built around it can wait.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Strip GitOps back to first principles and it is genuinely useful. The &lt;a href="https://opengitops.dev/" rel="noopener noreferrer"&gt;OpenGitOps principles&lt;/a&gt; give you four: desired state is declarative, versioned and immutable, pulled automatically, and continuously reconciled. Those get you reviewed change, a real audit trail, drift detection and rollback by &lt;code&gt;git revert&lt;/code&gt;, and none of them needs an elaborate delivery platform.&lt;/p&gt;

&lt;p&gt;If you already use Helm, most of the pieces are in place. What you need on top is small: charts and values files in Git, one reconciler to apply them, a values layout that keeps environments honest, and a secrets plan that is not reckless. This post takes each in turn, then covers the machinery you can leave alone and the sharp edges you will meet once the loop is running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The useful bit of GitOps is boring in the best way
&lt;/h2&gt;

&lt;p&gt;Once a tool such as Argo CD or Flux is watching a repository and rendering your charts from it, a few things improve straight away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Change becomes reviewable.&lt;/strong&gt; Every deploy is a commit to a chart or a values file, and usually a pull request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback gets simpler.&lt;/strong&gt; In the happy path it is a &lt;code&gt;git revert&lt;/code&gt; rather than a reconstruction job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift becomes visible.&lt;/strong&gt; When someone changes a live object out of band, the reconciler notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster state becomes reproducible.&lt;/strong&gt; You stop relying on memory and shell history to explain which values produced what is running.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That rollback point changes one Helm habit. Under GitOps, &lt;code&gt;helm rollback&lt;/code&gt; stops being the tool for the job. On Argo CD there is no Helm release to roll back, and on Flux a manual rollback leaves the release out of step with what Git declares, so the controller upgrades it straight back. Revert the commit instead and let the loop do the deploy.&lt;/p&gt;

&lt;p&gt;All of this is a meaningful operational upgrade at any size, and it lines up with DORA's research. Its &lt;a href="https://dora.dev/capabilities/continuous-delivery/" rel="noopener noreferrer"&gt;continuous delivery capability&lt;/a&gt; lists version control for all production artefacts, and deployment automation, among the practices that drive continuous delivery, which in turn improves delivery performance. I would be careful not to overclaim here. DORA does not measure GitOps as a separate intervention with a tidy uplift figure. What it does back is the discipline GitOps happens to enforce well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the loop
&lt;/h2&gt;

&lt;p&gt;If I were introducing GitOps to a team that already ships Helm charts, I would start with a setup that feels almost underwhelming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;charts and their values in Git, with environment differences as values files on one branch&lt;/li&gt;
&lt;li&gt;one reconciler running in the cluster&lt;/li&gt;
&lt;li&gt;pull requests as the only normal path into production&lt;/li&gt;
&lt;li&gt;chart versions pinned, so the same commit always renders the same manifests&lt;/li&gt;
&lt;li&gt;automated sync switched on, with pruning and self-heal treated as things the team earns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point deserves a closer look, because the two main tools handle it differently. Self-healing sounds great until an urgent out-of-band fix gets reverted mid-incident because Git still says otherwise. The controller is doing what you asked, not what you meant five minutes into an outage.&lt;/p&gt;

&lt;p&gt;Argo CD leaves both sharp edges off by default. An &lt;code&gt;Application&lt;/code&gt; with automated sync and nothing else set behaves like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="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;false&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;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With those settings, Argo CD syncs when a new commit lands. It does not delete resources that disappear from Git, and it does not revert a live change on its own: the drift shows up as &lt;code&gt;OutOfSync&lt;/code&gt; for a human to deal with. Turn on &lt;code&gt;selfHeal&lt;/code&gt; and &lt;code&gt;prune&lt;/code&gt; once the team trusts the loop and, more to the point, once everyone has stopped fixing production by hand.&lt;/p&gt;

&lt;p&gt;Flux splits the same decision across its two controllers, and the split catches Helm users out. A plain &lt;code&gt;Kustomization&lt;/code&gt; re-applies what Git says on every &lt;code&gt;interval&lt;/code&gt; and &lt;a href="https://fluxcd.io/flux/components/kustomize/kustomizations/" rel="noopener noreferrer"&gt;corrects any drift it finds&lt;/a&gt;, with no switch to turn that off. A &lt;code&gt;HelmRelease&lt;/code&gt; is different: &lt;a href="https://fluxcd.io/flux/components/helm/helmreleases/#drift-detection" rel="noopener noreferrer"&gt;drift detection is opt-in&lt;/a&gt;, so by default a &lt;code&gt;kubectl edit&lt;/code&gt; against a Helm-managed Deployment stays put until the next chart or values change triggers an upgrade. You choose the behaviour explicitly:&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;driftDetection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warn&lt;/span&gt; &lt;span class="c1"&gt;# report drift as an event; change to `enabled` to correct it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;warn&lt;/code&gt; is the Flux equivalent of Argo CD with self-heal off, and a sensible place to start. The section on running the loop comes back to what to do when you need the reconciler to back off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Argo CD vs Flux: pick by how your team works
&lt;/h2&gt;

&lt;p&gt;People love turning this into theology. It doesn't need to be.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.cncf.io/projects/flux/" rel="noopener noreferrer"&gt;Flux&lt;/a&gt; and &lt;a href="https://www.cncf.io/projects/argo/" rel="noopener noreferrer"&gt;Argo, the project that includes Argo CD&lt;/a&gt;, reached CNCF Graduated status within a week of each other in late 2022. Either can be the right answer, and neither is a toy. The first difference is how people expect to interact with deployments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Argo CD&lt;/strong&gt; suits teams that want an app-centric control plane with a strong UI. You declare &lt;code&gt;Application&lt;/code&gt; resources and get a visual model of sync status, health and drift, so people can see what is going on without living in the CLI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flux&lt;/strong&gt; suits teams that prefer a composable set of controllers and an experience shaped around Git, the CLI and the Kubernetes API. It feels closer to assembling the controllers you need than to logging into a deployment console.&lt;/p&gt;

&lt;p&gt;The second difference is specific to Helm, and it is worth knowing before you pick. Argo CD &lt;a href="https://argo-cd.readthedocs.io/en/stable/faq/" rel="noopener noreferrer"&gt;uses Helm only as a template engine&lt;/a&gt;: it runs &lt;code&gt;helm template&lt;/code&gt; and applies the rendered manifests itself, so &lt;code&gt;helm list&lt;/code&gt; shows nothing and there is no Helm release history in the cluster. Flux's helm-controller performs real Helm installs and upgrades, so releases, history and &lt;code&gt;helm list&lt;/code&gt; all behave as they did before GitOps, and failed upgrades can be remediated with Helm's own rollback. Neither approach is wrong. If your team leans on Helm tooling and release history, Flux will feel familiar; if you would rather Helm stayed a rendering step and the Argo CD UI became the source of truth for what is deployed, Argo CD's model is simpler to reason about.&lt;/p&gt;

&lt;p&gt;So my heuristic is mostly a social one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pick &lt;strong&gt;Argo CD&lt;/strong&gt; if people routinely want to look at deploy state and reason about it in one place&lt;/li&gt;
&lt;li&gt;pick &lt;strong&gt;Flux&lt;/strong&gt; if your team is happy in Git and the terminal, wants real Helm releases, and would rather compose small controllers than run a dashboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The secrets section below adds one more technical tie-breaker. If you want to go further with Argo, we have a hands-on post on &lt;a href="https://dev.to/blog/gitops-for-kubernetes-with-argo-cd"&gt;GitOps for Kubernetes with Argo CD&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One repo per service, chart included
&lt;/h2&gt;

&lt;p&gt;Repository debates get oddly ideological. Keep it plain: each service is its own repo, and its Helm chart lives next to the code it deploys. The chart then versions with the application, so a change that needs a new environment variable and the code that reads it lands in one pull request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;payments-api/
├── src/
├── Dockerfile
└── chart/
    ├── Chart.yaml
    ├── values.yaml          # base: what every environment shares
    ├── values/
    │   ├── staging.yaml     # only what differs in staging
    │   └── production.yaml  # only what differs in production
    └── templates/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The layout mirrors how Helm already merges values: a base that every environment shares, and a thin file per environment that only says what differs. In Argo CD, each environment's &lt;code&gt;Application&lt;/code&gt; points at the service repo's &lt;code&gt;chart/&lt;/code&gt; directory and names its environment file. The chart's own &lt;code&gt;values.yaml&lt;/code&gt; is always the base, and anything in &lt;code&gt;valueFiles&lt;/code&gt; is &lt;a href="https://argo-cd.readthedocs.io/en/stable/user-guide/helm/" rel="noopener noreferrer"&gt;layered on top of it&lt;/a&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="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/example/payments-api.git&lt;/span&gt;
  &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.4.2&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chart&lt;/span&gt;
  &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;valueFiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;values/production.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flux does the same with a &lt;code&gt;HelmRelease&lt;/code&gt; whose chart comes from the service's &lt;code&gt;GitRepository&lt;/code&gt;. Its &lt;code&gt;valuesFiles&lt;/code&gt; &lt;a href="https://fluxcd.io/flux/components/source/helmcharts/" rel="noopener noreferrer"&gt;replace the chart's default values&lt;/a&gt; and merge in order, with paths relative to the repo root, so list the base first:&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;chart&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;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./chart&lt;/span&gt;
      &lt;span class="na"&gt;sourceRef&lt;/span&gt;&lt;span class="pi"&gt;:&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;GitRepository&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;payments-api&lt;/span&gt;
      &lt;span class="na"&gt;valuesFiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./chart/values.yaml&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./chart/values/production.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those &lt;code&gt;Application&lt;/code&gt; and &lt;code&gt;HelmRelease&lt;/code&gt; definitions are the only thing that isn't per service: a small deployments repo holding one per service and environment, which is also where the reconciler reads what runs where.&lt;/p&gt;

&lt;p&gt;Argo CD's &lt;a href="https://argo-cd.readthedocs.io/en/stable/user-guide/best_practices/" rel="noopener noreferrer"&gt;best-practices guide&lt;/a&gt; recommends keeping config in a separate repo from application source, and its reasons are worth handling rather than ignoring. A values-only change shouldn't trigger a full CI build, so path-filter the pipeline. CI shouldn't commit image tags back into the repo it builds from, or you get a build loop. And a code commit shouldn't reach production on its own, which is what the pinned &lt;code&gt;targetRevision&lt;/code&gt; above is for: staging tracks &lt;code&gt;main&lt;/code&gt;, production pins a release, and promotion is a reviewed pull request that bumps the pin.&lt;/p&gt;

&lt;p&gt;A few opinions, stated plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use values files for environments, not branches.&lt;/strong&gt; Branch-per-environment sounds tidy until promotion turns into cherry-picking and drift archaeology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep environment files thin.&lt;/strong&gt; If &lt;code&gt;production.yaml&lt;/code&gt; is most of a copy of &lt;code&gt;values.yaml&lt;/code&gt;, the base isn't doing its job, and the next shared change will land in one file and not the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin what production runs.&lt;/strong&gt; Pin the service's &lt;code&gt;targetRevision&lt;/code&gt; to a tag or commit, and pin upstream chart versions exactly rather than to a range such as &lt;code&gt;6.5.*&lt;/code&gt;. Let a bot such as &lt;a href="https://dev.to/blog/renovate-kubernetes-helm-terraform"&gt;Renovate&lt;/a&gt; raise the bumps as pull requests. A range means the same commit can render different manifests next week.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One Helm behaviour bites almost everyone who adopts this layout. Helm merges maps deeply, but it &lt;a href="https://github.com/helm/helm/blob/main/pkg/chart/common/util/coalesce.go" rel="noopener noreferrer"&gt;replaces lists outright&lt;/a&gt;: "scalar values and arrays are replaced, maps are merged". So a &lt;code&gt;production.yaml&lt;/code&gt; that adds one environment variable to an &lt;code&gt;env:&lt;/code&gt; list doesn't add it; it replaces every entry the base defined, and the base's variables silently vanish from production. Keep list-valued settings in one file, or have the chart accept a map and render the list from it.&lt;/p&gt;

&lt;p&gt;Shared infrastructure follows the same rule: cert-manager, ingress and External Secrets are each their own chart, deployed before the services that use them. Your charts will create custom resources, such as a cert-manager &lt;code&gt;Certificate&lt;/code&gt; or an &lt;code&gt;ExternalSecret&lt;/code&gt;, and those only work once the CRDs exist and the controller behind them is actually running. It is also a Helm limitation: Helm &lt;a href="https://helm.sh/docs/chart_best_practices/custom_resource_definitions/" rel="noopener noreferrer"&gt;never upgrades or deletes CRDs&lt;/a&gt; from a chart's &lt;code&gt;crds/&lt;/code&gt; directory, and its own docs suggest a separate chart for them. Flux handles the ordering with &lt;a href="https://fluxcd.io/flux/components/helm/helmreleases/" rel="noopener noreferrer"&gt;&lt;code&gt;dependsOn&lt;/code&gt;&lt;/a&gt;, so a service's release waits until the infrastructure it needs reports ready. Argo CD uses &lt;a href="https://argo-cd.readthedocs.io/en/stable/user-guide/sync-waves/" rel="noopener noreferrer"&gt;sync waves&lt;/a&gt;, set with the &lt;code&gt;argocd.argoproj.io/sync-wave&lt;/code&gt; annotation, which order resources within a single Application. Ordering whole Applications needs app-of-apps plus a custom health check for the &lt;code&gt;Application&lt;/code&gt; kind, and that is one of the few good reasons to adopt app-of-apps early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets in GitOps: SOPS, Sealed Secrets or External Secrets
&lt;/h2&gt;

&lt;p&gt;Every GitOps conversation eventually arrives at the part the tidy diagrams leave out, and with Helm the first place a secret leaks is a values file. "Just put them in Git" is not a serious answer. Git is durable, replicated, and very good at remembering things you wish it had forgotten.&lt;/p&gt;

&lt;p&gt;The usual options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SOPS&lt;/strong&gt; encrypts the values in the files you commit, so diffs stay reviewable. &lt;a href="https://fluxcd.io/flux/guides/mozilla-sops/" rel="noopener noreferrer"&gt;Flux decrypts SOPS natively&lt;/a&gt; through &lt;code&gt;spec.decryption&lt;/code&gt; on a Kustomization, and a &lt;code&gt;HelmRelease&lt;/code&gt; can read the decrypted Secret as values through &lt;code&gt;valuesFrom&lt;/code&gt;. Argo CD has no built-in support, so you add SOPS to its repo server with a plugin. That moves decryption into manifest generation, which Argo CD's own &lt;a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/secret-management/" rel="noopener noreferrer"&gt;secret-management guide&lt;/a&gt; strongly cautions against, because generated manifests sit in plaintext in its Redis cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sealed Secrets&lt;/strong&gt; gives you a &lt;code&gt;SealedSecret&lt;/code&gt; that is safe to commit and that only the controller in the target cluster can decrypt. It is easy to explain and a fine early move, but it is cluster-bound. By default the controller renews its sealing key every 30 days and keeps the old ones, so disaster recovery means backing up every secret labelled &lt;code&gt;sealedsecrets.bitnami.com/sealed-secrets-key&lt;/code&gt;, and refreshing that backup after each renewal. Lose those keys along with the cluster and every &lt;code&gt;SealedSecret&lt;/code&gt; in Git is ciphertext nobody can open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External Secrets Operator&lt;/strong&gt; keeps references in Git and the values in a real secrets manager such as Vault, AWS Secrets Manager or Azure Key Vault. Your chart templates an &lt;code&gt;ExternalSecret&lt;/code&gt; instead of a &lt;code&gt;Secret&lt;/code&gt;, and the values file only ever holds the key's path. It is often the cleanest long-term model, and our post on &lt;a href="https://dev.to/blog/secrets-management-scale-external-secrets-operator"&gt;syncing secrets into Kubernetes with External Secrets&lt;/a&gt; walks through it. The trade-off is a hard runtime dependency on that external system, and on the operator itself: in 2025 the project paused releases for several weeks over maintainer burnout, until more maintainers joined. Anything on your runtime path deserves a look at who maintains it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Argo CD's guide recommends populating secrets on the destination cluster, which is what the last two do. Put that together and my bias looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;if you already have a cloud secrets manager, go straight to &lt;strong&gt;External Secrets Operator&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;if you don't and you run Flux, &lt;strong&gt;SOPS&lt;/strong&gt; is the cleanest Git-native option&lt;/li&gt;
&lt;li&gt;if you don't and you run Argo CD, start with &lt;strong&gt;Sealed Secrets&lt;/strong&gt; and treat the key backup as part of the install&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Picking the "wrong" tool is recoverable. The mistake that lingers is deferring the decision, migrating everything else, and leaving secrets as a loose end to tidy up later. Sort them out alongside the reconciler, because they are part of the same design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The machinery to skip until it hurts
&lt;/h2&gt;

&lt;p&gt;This is where small teams get talked into complexity they have not earned yet. Plenty of the canonical GitOps add-ons are real solutions, just to later problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;app-of-apps sprawl&lt;/strong&gt; solves large estates with many related deployments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ApplicationSet&lt;/code&gt; generators&lt;/strong&gt; solve repetition across fleets of charts and clusters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;promotion pipelines&lt;/strong&gt; solve controlled movement across several environments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;progressive delivery&lt;/strong&gt;, with a tool such as &lt;a href="https://dev.to/blog/argo-rollouts-kubernetes-canary-blue-green"&gt;Argo Rollouts&lt;/a&gt;, solves high-stakes releases where traffic shaping is worth another controller&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;multi-cluster GitOps&lt;/strong&gt; solves hard isolation, geography, tenancy or blast-radius constraints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If those are not your current pressures, adopting them early is overhead you pay every week for a problem you don't have yet.&lt;/p&gt;

&lt;p&gt;It is the same argument as our post on &lt;a href="https://dev.to/blog/stop-copying-big-tech-platform-architecture"&gt;right-sizing platform architecture&lt;/a&gt;: do not borrow scale machinery before the scale arrives. GitOps has the trap every fashionable platform idea has. The sensible core gets wrapped in an architecture identity, and teams start buying components to prove they are doing it properly. You are doing GitOps properly if Git is the source of truth and a reconciler is continuously working to make the cluster match it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the loop once it's live
&lt;/h2&gt;

&lt;p&gt;The machinery can wait. These can't, because every team meets them within a few weeks of switching the reconciler on, however small the estate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pause the loop before you fix production by hand.&lt;/strong&gt; During an incident the reconciler is on Git's side, not yours. In Flux, &lt;code&gt;flux suspend helmrelease &amp;lt;name&amp;gt;&lt;/code&gt; (or &lt;code&gt;flux suspend kustomization &amp;lt;name&amp;gt;&lt;/code&gt; for plain manifests) stops new revisions and drift correction, and the matching &lt;code&gt;flux resume&lt;/code&gt; turns them back on. In Argo CD, &lt;code&gt;argocd app set &amp;lt;app&amp;gt; --sync-policy none&lt;/code&gt; takes one Application off automated sync. There are two catches on the Argo side: an Application generated by an &lt;code&gt;ApplicationSet&lt;/code&gt; ignores changes to its own sync policy, and a parent app with self-heal on can put a child's policy straight back. Whatever you change by hand, commit the same change to the values file before you resume, or the loop will quietly undo your fix. Put those commands in the incident runbook now, while things are calm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the autoscaler own &lt;code&gt;replicas&lt;/code&gt;.&lt;/strong&gt; If a HorizontalPodAutoscaler manages a Deployment and your chart also renders &lt;code&gt;spec.replicas&lt;/code&gt;, the two fight, and every sync resets the count the HPA chose. The chart that &lt;code&gt;helm create&lt;/code&gt; scaffolds already handles this: its Deployment only renders &lt;code&gt;replicas&lt;/code&gt; when &lt;code&gt;autoscaling.enabled&lt;/code&gt; is false, so the fix is usually one value rather than a template change. Argo CD's best-practices guide and the &lt;a href="https://fluxcd.io/flux/faq/" rel="noopener noreferrer"&gt;Flux FAQ&lt;/a&gt; give the same advice: leave &lt;code&gt;replicas&lt;/code&gt; out of what you apply. If a third-party chart insists on rendering it, Argo CD's &lt;a href="https://argo-cd.readthedocs.io/en/stable/user-guide/diffing/" rel="noopener noreferrer"&gt;&lt;code&gt;ignoreDifferences&lt;/code&gt;&lt;/a&gt; on &lt;code&gt;/spec/replicas&lt;/code&gt; hides it from the diff, but the sync still applies it unless you also set the &lt;code&gt;RespectIgnoreDifferences=true&lt;/code&gt; sync option. On Flux, a &lt;code&gt;HelmRelease&lt;/code&gt; takes &lt;code&gt;driftDetection.ignore&lt;/code&gt; with &lt;code&gt;paths: ["/spec/replicas"]&lt;/code&gt;, which its docs suggest for exactly this case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preview what a pull request will actually render.&lt;/strong&gt; With Helm, reading a values diff is not the same as knowing what the manifests will look like, because one value can fan out across a dozen templates. The cheapest check needs no cluster at all: run &lt;code&gt;helm template&lt;/code&gt; with the base and environment files on your branch and on &lt;code&gt;main&lt;/code&gt;, and diff the two outputs. For the live comparison, &lt;code&gt;argocd app diff &amp;lt;app&amp;gt; --revision &amp;lt;branch&amp;gt;&lt;/code&gt; renders the chart and compares it with what is running, with one gap: it leaves Secrets out. &lt;code&gt;flux diff kustomization&lt;/code&gt; is less useful here, because for a &lt;code&gt;HelmRelease&lt;/code&gt; it shows the change to the &lt;code&gt;HelmRelease&lt;/code&gt; object rather than to the rendered manifests. Running a live diff in CI means giving CI read access to the cluster, which is a real trade for a pull-based setup. Running it locally before you open the pull request costs nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the loop react faster than it polls.&lt;/strong&gt; By default Argo CD checks Git every two minutes plus up to a minute of jitter, and Flux fetches each &lt;code&gt;GitRepository&lt;/code&gt; on whatever &lt;code&gt;interval&lt;/code&gt; you give it. A merged pull request that appears to do nothing for a few minutes is usually waiting for the next poll. Both tools accept webhooks: Argo CD's API server takes &lt;a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/webhook/" rel="noopener noreferrer"&gt;Git webhook events&lt;/a&gt; directly, and Flux uses a &lt;a href="https://fluxcd.io/flux/guides/webhook-receivers/" rel="noopener noreferrer"&gt;&lt;code&gt;Receiver&lt;/code&gt;&lt;/a&gt; from its notification controller. Keep polling as the fallback, so a missed webhook costs you minutes rather than a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;Pick one low-risk chart, ideally a stateless internal service, and move it to the base-plus-environment layout: &lt;code&gt;values.yaml&lt;/code&gt; for what is shared, &lt;code&gt;values/production.yaml&lt;/code&gt; for the handful of lines that differ. Put it under the reconciler with automated sync on and pruning off, and on Flux set &lt;code&gt;driftDetection.mode: warn&lt;/code&gt;. Make a pull request the only way it changes for a fortnight, and keep a note of every drift the reconciler reports, whether that is Argo CD marking the app &lt;code&gt;OutOfSync&lt;/code&gt; or Flux emitting a drift event on the &lt;code&gt;HelmRelease&lt;/code&gt;. Every one of those is a hand-edit or a &lt;code&gt;--set&lt;/code&gt; someone still reaches for, and each becomes either a runbook entry, a value that belongs in Git, or a field handed over to another controller. When a week goes by with nothing to explain, turn on self-heal and pruning, move the next chart across, and leave the rest of the GitOps catalogue on the shelf until a real problem asks for it.&lt;/p&gt;

&lt;p&gt;For a small team, most of the effort in this post goes into standing the loop up rather than using it. That is the part we built &lt;a href="https://kupe.cloud" rel="noopener noreferrer"&gt;Kupe Cloud&lt;/a&gt; to take away. It is our managed Kubernetes platform, and both halves of the setup are ready as soon as you spin up your first cluster. Argo CD is already running, with a project set up for your tenant, so once you connect a service repo laid out as above, its chart deploys as it is. Secrets work the same way from day one: you create a secret once in the console, it is stored encrypted in the platform's vault, and Kupe syncs it as a Kubernetes &lt;code&gt;Secret&lt;/code&gt; into the clusters and namespaces you choose. That is the External Secrets model from earlier, with nothing extra to install or run. Everything else in this post still applies; you just start with the loop already running.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>helm</category>
      <category>argocd</category>
    </item>
    <item>
      <title>Falco on Kubernetes: install, tune, and route alerts</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Tue, 22 Sep 2026 18:28:51 +0000</pubDate>
      <link>https://dev.to/coresolutions/falco-on-kubernetes-install-tune-and-route-alerts-1la1</link>
      <guid>https://dev.to/coresolutions/falco-on-kubernetes-install-tune-and-route-alerts-1la1</guid>
      <description>&lt;p&gt;You scan images in CI, sign them before deploy, and gate manifests at admission. Every one of those controls answers the same question: should this workload be allowed to start? None of them can tell you what it did at 02:00 once it was running. Someone opening a shell in a production pod, a binary that was never in the image suddenly executing, a process reading &lt;code&gt;/etc/shadow&lt;/code&gt; for no good reason. Those are runtime events, and runtime is where Falco lives.&lt;/p&gt;

&lt;p&gt;The catch is that Falco tutorials age faster than most. The project has dropped its legacy eBPF probe, its gVisor engine and its gRPC output in recent releases, deprecated the &lt;code&gt;append: true&lt;/code&gt; rule syntax in favour of &lt;code&gt;override:&lt;/code&gt;, and moved rule distribution onto OCI artifacts pulled by a sidecar. A lot of the snippets still ranking on the first page will not load on a current install. So this walkthrough sticks to what the current chart and docs actually do: install, trigger one stock alert, write one custom rule, and then spend most of the time on tuning, which is the part that decides whether Falco is still switched on in six months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where runtime detection fits
&lt;/h2&gt;

&lt;p&gt;A Kubernetes security stack has layers, and each one answers a different question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;image scanning: what went into the artefact&lt;/li&gt;
&lt;li&gt;signing and provenance: whether you trust where it came from&lt;/li&gt;
&lt;li&gt;admission policy: whether the cluster should accept it&lt;/li&gt;
&lt;li&gt;runtime detection: what the workload is doing &lt;em&gt;now&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last layer is the one teams skip until something odd is already running in production. Falco fills it by watching syscalls from the kernel, matching them against a rules engine in userspace, and emitting an alert when a rule fires. If you want the eBPF side of that story in more depth, the &lt;a href="https://dev.to/blog/ebpf-powered-networking-on-kubernetes-with-cilium"&gt;Cilium networking post&lt;/a&gt; covers how the same machinery is used for the network path.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Falco collects events, and where it differs from Tetragon
&lt;/h2&gt;

&lt;p&gt;Search for "Falco vs eBPF" and you will find a lot of confused threads. Falco is not an alternative to eBPF. It uses eBPF as one way of collecting kernel events, and there are two supported drivers today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;modern_ebpf&lt;/code&gt;, a CO-RE probe embedded in the binary, which is what Falco itself defaults to&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kmod&lt;/code&gt;, the kernel module, still supported and still the right answer on kernels the modern probe cannot run on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Helm chart adds a third value, &lt;code&gt;auto&lt;/code&gt;, which is its default. It tries the modern probe first and falls back to the kernel module. The older &lt;code&gt;ebpf&lt;/code&gt; and &lt;code&gt;gvisor&lt;/code&gt; driver kinds are gone: the chart will not even render if you pass either, which is a kinder failure than a pod that starts and silently sees nothing.&lt;/p&gt;

&lt;p&gt;Tetragon, from the Cilium project, makes a different trade-off. It leans into in-kernel enforcement: a tracing policy can override a kernel function's return value or send &lt;code&gt;SIGKILL&lt;/code&gt; to the offending process before it gets any further. Falco keeps the decision in userspace, with a mature rules engine, a maintained default ruleset, and a lot of flexibility around outputs and tuning. If your first question is "what happened inside that container?", Falco is the more natural place to start. If it is "stop that from ever completing", look at Tetragon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install Falco with the Helm chart
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update
helm &lt;span class="nb"&gt;install &lt;/span&gt;falco falcosecurity/falco &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; falco &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; falcosidekick.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; falcosidekick.webui.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; collectors.kubernetes.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those three flags are worth understanding rather than copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;falcosidekick.enabled=true&lt;/code&gt; deploys Falcosidekick and switches Falco to JSON output over HTTP, so alerts can be forwarded to systems people already watch&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;falcosidekick.webui.enabled=true&lt;/code&gt; adds the web UI and a Redis instance behind it, which is handy while you are testing and worth turning off once alerts flow somewhere durable&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;collectors.kubernetes.enabled=true&lt;/code&gt; deploys the metadata collector and the &lt;code&gt;k8smeta&lt;/code&gt; plugin, which adds the owning Deployment, ReplicaSet and Service to each alert. Without it you still get the pod name, namespace and labels, because Falco reads those from the container runtime directly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The chart also deploys two containers you did not ask for by name: a &lt;code&gt;falcoctl&lt;/code&gt; init container that pulls the default rules as an OCI artifact, and a &lt;code&gt;falcoctl&lt;/code&gt; sidecar that checks for a newer artifact once a week and swaps it in. That has consequences for tuning, which we will come back to.&lt;/p&gt;

&lt;p&gt;If you need to be explicit about the driver, use one of the two current values:&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="nt"&gt;--set&lt;/span&gt; driver.kind&lt;span class="o"&gt;=&lt;/span&gt;modern_ebpf   &lt;span class="c"&gt;# or driver.kind=kmod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then watch the Falco container start. The pod has several containers now, so name the one you want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; falco logs ds/falco &lt;span class="nt"&gt;-c&lt;/span&gt; falco
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are looking for a clean driver load and a &lt;code&gt;Loading rules from file&lt;/code&gt; line for each rules file. If the driver is wrong for the kernel underneath, this is where you find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trigger one stock alert first
&lt;/h2&gt;

&lt;p&gt;Before writing anything custom, prove the default path works. Start a throwaway pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run runtime-test &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;3600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then exec into it with a terminal attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; runtime-test &lt;span class="nt"&gt;--&lt;/span&gt; sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fires the stock &lt;code&gt;Terminal shell in container&lt;/code&gt; rule. The &lt;code&gt;-t&lt;/code&gt; is doing real work here: the rule checks that the shell has a TTY, so &lt;code&gt;kubectl exec runtime-test -- sh -c 'id'&lt;/code&gt; without one will not trigger it. Check the Falco logs or the Falcosidekick UI and you should see the alert with &lt;code&gt;container_id&lt;/code&gt; and &lt;code&gt;container_name&lt;/code&gt; appended to the end of the output line. Those fields are not in the rule's own output string; the container plugin suggests them and Falco appends suggested fields automatically.&lt;/p&gt;

&lt;p&gt;This is also the moment to reset expectations about volume. The stock &lt;code&gt;falco_rules.yaml&lt;/code&gt; is a small, conservative file: 25 stable rules at the time of writing, every one enabled, none of them experimental. The noisy reputation comes from the incubating and sandbox rulesets, which the chart does not load by default, and from teams tuning carelessly once they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write one custom rule
&lt;/h2&gt;

&lt;p&gt;The simplest useful rule is one you can explain in a sentence: tell me if a miner-like binary starts inside a container. With the chart, custom rules go in the &lt;code&gt;customRules&lt;/code&gt; value, and each key becomes a file under &lt;code&gt;/etc/falco/rules.d/&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="na"&gt;customRules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;miners.yaml&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
    &lt;span class="s"&gt;- rule: Crypto miner process in container&lt;/span&gt;
      &lt;span class="s"&gt;desc: Detect common miner binaries starting inside a container&lt;/span&gt;
      &lt;span class="s"&gt;condition: &amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;spawned_process and container and&lt;/span&gt;
        &lt;span class="s"&gt;proc.name = anyof (xmrig, minerd, cryptominer)&lt;/span&gt;
      &lt;span class="s"&gt;output: &amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;Crypto miner process in container | user=%user.name&lt;/span&gt;
        &lt;span class="s"&gt;process=%proc.name command=%proc.cmdline&lt;/span&gt;
      &lt;span class="s"&gt;priority: WARNING&lt;/span&gt;
      &lt;span class="s"&gt;tags: [container, malware, crypto-miner]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things about that condition. &lt;code&gt;spawned_process&lt;/code&gt; and &lt;code&gt;container&lt;/code&gt; are macros from the stock file, so you get exec events inside containers for free. And &lt;code&gt;anyof&lt;/code&gt; is a comparator modifier from the current rule language: one field compared against a short list, without a chain of &lt;code&gt;or&lt;/code&gt; clauses. It sits after the operator, with a space either side and the values in parentheses.&lt;/p&gt;

&lt;p&gt;One trap that is not in the tutorials: &lt;code&gt;proc.name&lt;/code&gt; is the kernel's process name, and the kernel truncates it to 15 characters. The stock rules file depends on this, which is why you will find entries like &lt;code&gt;mysql_install_d&lt;/code&gt; in its lists. If your binary has a long name, match on &lt;code&gt;proc.exepath&lt;/code&gt; or &lt;code&gt;container.image.repository&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;Load order matters. The default &lt;code&gt;rules_files&lt;/code&gt; list reads &lt;code&gt;falco_rules.yaml&lt;/code&gt;, then &lt;code&gt;falco_rules.local.yaml&lt;/code&gt;, then everything in &lt;code&gt;rules.d&lt;/code&gt;, and a file that overrides a stock rule has to load after it. Give the file a &lt;code&gt;.yaml&lt;/code&gt; or &lt;code&gt;.yml&lt;/code&gt; extension too, because anything else in that directory is ignored.&lt;/p&gt;

&lt;p&gt;Before you apply, validate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;falco &lt;span class="nt"&gt;-V&lt;/span&gt; miners.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That parses and compiles the rules file and exits, and the easiest place to run it is the same Falco image tag you deploy, so the engine version matches. It is worth the thirty seconds, because a rules error is fatal. Falco stops at the first file that fails to load, prints the reason, and exits non-zero, which on Kubernetes means the whole DaemonSet goes into &lt;code&gt;CrashLoopBackOff&lt;/code&gt; over one bad line in one file. Warnings are logged and tolerated. Errors are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning is where most Falco deployments succeed or fail
&lt;/h2&gt;

&lt;p&gt;Install is the easy part. Tuning decides whether people trust the alerts next month, and the stock rules file is built for it in a specific way that most tutorials, including the official exceptions examples, obscure.&lt;/p&gt;

&lt;p&gt;The fact the docs bury is that &lt;strong&gt;none of the stable stock rules ships an &lt;code&gt;exceptions:&lt;/code&gt; block.&lt;/strong&gt; The docs' canonical example appends values to an exception on &lt;code&gt;Write below binary dir&lt;/code&gt;, and that rule lives in the sandbox ruleset, not the one you just installed. Copy it against a stable rule and the loader rejects it, because there is no existing exception to inherit fields from.&lt;/p&gt;

&lt;p&gt;What the stable rules give you instead is a set of hooks with names starting &lt;code&gt;user_&lt;/code&gt;, each defined as &lt;code&gt;(never_true)&lt;/code&gt;, plus empty image lists. Take &lt;code&gt;Contact K8S API Server From Container&lt;/code&gt;, which fires the first time an operator or controller you run outside &lt;code&gt;kube-system&lt;/code&gt; talks to the API server. Its condition ends with &lt;code&gt;and not user_known_contact_k8s_api_server_activities&lt;/code&gt;, and that macro is yours to replace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;macro&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user_known_contact_k8s_api_server_activities&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;(k8s.ns.name = argocd and&lt;/span&gt;
     &lt;span class="s"&gt;container.image.repository = quay.io/argoproj/argocd)&lt;/span&gt;
  &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;replace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same shape works for the other stable rules. &lt;code&gt;Terminal shell in container&lt;/code&gt; has &lt;code&gt;user_expected_terminal_shell_in_container_conditions&lt;/code&gt;. &lt;code&gt;Read sensitive file untrusted&lt;/code&gt; has a &lt;code&gt;read_sensitive_file_images&lt;/code&gt; list you can append to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read_sensitive_file_images&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;quay.io/argoproj/argocd&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;append&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather use exceptions, you can. Appending a &lt;em&gt;new&lt;/em&gt; named exception to a stable rule is supported as long as you supply &lt;code&gt;fields&lt;/code&gt; alongside &lt;code&gt;values&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="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Contact K8S API Server From Container&lt;/span&gt;
  &lt;span class="na"&gt;exceptions&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;argocd_controllers&lt;/span&gt;
      &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;container.image.repository&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;k8s.ns.name&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;values&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;quay.io/argoproj/argocd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;argocd&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;exceptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;append&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Falco adds &lt;code&gt;and not (container.image.repository = ... and k8s.ns.name = ...)&lt;/code&gt; to the condition for you. Either approach beats disabling the rule, because the rule is still right for every other workload.&lt;/p&gt;

&lt;p&gt;When a rule really does not fit your threat model, turn it off explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Drop and execute new binary in container&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;replace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;override:&lt;/code&gt; block is the part older posts get wrong. It states, per key, whether you are appending to or replacing the stock definition. &lt;code&gt;append: true&lt;/code&gt; still parses for now but is deprecated, so translate any snippet built around it before you paste.&lt;/p&gt;

&lt;p&gt;One more thing the file already did for you: the API-server rule's &lt;code&gt;k8s_containers&lt;/code&gt; macro exempts everything in &lt;code&gt;kube-system&lt;/code&gt;. If it is firing, the workload is somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route alerts somewhere people already look
&lt;/h2&gt;

&lt;p&gt;An alert that only exists in one DaemonSet's log stream changes nothing. Falcosidekick takes Falco's JSON output and fans it out to Slack, Loki, Elasticsearch, Splunk, Datadog, Alertmanager and a long list beyond that. The chart values map directly to its outputs:&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;falcosidekick&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;slack&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;webhookurl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://hooks.slack.com/services/XXXX/YYYY/ZZZZ&lt;/span&gt;
      &lt;span class="na"&gt;minimumpriority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
    &lt;span class="na"&gt;loki&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;hostport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://loki-gateway.monitoring:80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no global minimum priority in Falcosidekick. Each output has its own &lt;code&gt;minimumpriority&lt;/code&gt;, and its severity ladder spells the level &lt;code&gt;informational&lt;/code&gt; rather than Falco's &lt;code&gt;info&lt;/code&gt;, which catches people out when a filter silently matches nothing.&lt;/p&gt;

&lt;p&gt;The pattern that holds up on platform teams is a split by urgency: Slack or Teams for the handful of rules that need a human now, a log backend such as Loki for searchable history, and the rest filtered out at the output. If Grafana is already your front door, the &lt;a href="https://dev.to/blog/enhance-your-kubernetes-monitoring-with-grafana"&gt;Grafana monitoring post&lt;/a&gt; covers getting Grafana itself stood up and the first dashboards in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operating it after week one
&lt;/h2&gt;

&lt;p&gt;The first three sections get Falco running. These are the three things that go wrong once it has been running for a while.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your rules are floating.&lt;/strong&gt; The &lt;code&gt;falcoctl&lt;/code&gt; sidecar follows a major-version tag of the rules artifact and installs whatever is newest every 168 hours, into an &lt;code&gt;emptyDir&lt;/code&gt; mounted over &lt;code&gt;/etc/falco&lt;/code&gt;. Two consequences: an edited &lt;code&gt;falco_rules.yaml&lt;/code&gt; inside the pod is overwritten on the next restart or the next follow, and a rule you tuned by name can change underneath you on a Tuesday. Keep every override in &lt;code&gt;customRules&lt;/code&gt;, and pin &lt;code&gt;falcoctl.config.artifact.install.refs&lt;/code&gt; and &lt;code&gt;follow.refs&lt;/code&gt; to an exact artifact tag or an &lt;code&gt;@sha256:&lt;/code&gt; digest once you are past the exploring stage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You are dropping events and nobody knows.&lt;/strong&gt; Falco emits an internal alert named &lt;code&gt;Falco internal: syscall event drop&lt;/code&gt; when the kernel ring buffer overflows, but it is a &lt;code&gt;debug&lt;/code&gt;-priority alert rate-limited to one message every 30 seconds. Raise Falco's minimum &lt;code&gt;priority&lt;/code&gt; to cut noise, or set &lt;code&gt;minimumpriority: warning&lt;/code&gt; on your Slack output, and it vanishes. The durable answer is metrics:&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="nt"&gt;--set&lt;/span&gt; metrics.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--set&lt;/span&gt; serviceMonitor.create&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turns on Falco's Prometheus endpoint and a ServiceMonitor for it. The two series to graph against each other are &lt;code&gt;falcosecurity_scap_n_drops_total&lt;/code&gt; and &lt;code&gt;falcosecurity_scap_n_evts_total&lt;/code&gt;. If drops climb with load, the documented remedies are a bigger ring buffer via &lt;code&gt;engine.modern_ebpf.buf_size_preset&lt;/code&gt;, fewer CPUs per buffer via &lt;code&gt;engine.modern_ebpf.cpus_for_each_buffer&lt;/code&gt;, and &lt;code&gt;base_syscalls&lt;/code&gt; to narrow what the driver captures. Which of those helps depends on your syscall mix, which is also why there is no universal "Falco overhead" figure worth quoting: it tracks what your workloads do, not a percentage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You want to act, not just alert.&lt;/strong&gt; Falco itself only detects. Falco Talon is the incubating response engine in the same ecosystem: it receives events from Falcosidekick's &lt;code&gt;talon&lt;/code&gt; output and applies rules such as terminating the pod or labelling it for quarantine. Its Kubernetes actions need &lt;code&gt;k8s.pod.name&lt;/code&gt; and &lt;code&gt;k8s.ns.name&lt;/code&gt; among the event's output fields, which the stock rule outputs do not carry, so expect to append those to any rule you want it to act on. It is receiving commits but has had long gaps between tagged releases, so treat it as something to evaluate rather than a default, and keep the enforcement question in mind when you weigh it against Tetragon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure you will hit first
&lt;/h2&gt;

&lt;p&gt;Almost every first Falco tuning session ends one of two ways. Either the DaemonSet is crash-looping, in which case the Falco container's log has the offending file and line near the top and &lt;code&gt;falco -V&lt;/code&gt; would have told you first. Or the pods are healthy and the override seems to do nothing. For the second, check three things before you touch the rule. Confirm the file landed in &lt;code&gt;/etc/falco/rules.d/&lt;/code&gt; with a &lt;code&gt;.yaml&lt;/code&gt; extension and appears in the &lt;code&gt;Loading rules from file&lt;/code&gt; lines. Check the rule name you are overriding still exists with that exact spelling in the ruleset the &lt;code&gt;falcoctl&lt;/code&gt; sidecar just installed. Then check whether the workload is already exempt, the way &lt;code&gt;kube-system&lt;/code&gt; is for the API-server rule. Usually the rule was fine and the plumbing was not.&lt;/p&gt;

</description>
      <category>security</category>
      <category>kubernetes</category>
      <category>falco</category>
      <category>runtimesecurity</category>
    </item>
    <item>
      <title>CIS Benchmark for Kubernetes with kube-bench and Kubescape</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Thu, 17 Sep 2026 17:51:08 +0000</pubDate>
      <link>https://dev.to/coresolutions/cis-benchmark-for-kubernetes-with-kube-bench-and-kubescape-38je</link>
      <guid>https://dev.to/coresolutions/cis-benchmark-for-kubernetes-with-kube-bench-and-kubescape-38je</guid>
      <description>&lt;p&gt;Most teams get told to “harden Kubernetes to CIS” long before anyone explains what happens after the first scan. You run the tool, it prints a wall of &lt;code&gt;FAIL&lt;/code&gt; and &lt;code&gt;WARN&lt;/code&gt;, half the findings turn out to be somebody else’s problem on a managed control plane, and the rest sit in a ticket queue until the next audit comes round. There is a narrower version that works. Use &lt;strong&gt;kube-bench&lt;/strong&gt; for the host and control-plane checks only it can see, use &lt;strong&gt;Kubescape&lt;/strong&gt; for the posture checks you want to keep running from the API side, and treat the &lt;a href="https://www.cisecurity.org/benchmark/kubernetes" rel="noopener noreferrer"&gt;CIS Kubernetes Benchmark&lt;/a&gt; as a triage tool rather than a moral score.&lt;/p&gt;

&lt;p&gt;One thing to know before either tool runs: the scanners trail the benchmark. CIS revises the benchmark as new Kubernetes releases land, and each tool picks the revision up some time later, as a new profile that somebody has to write and merge. On a recently upgraded cluster there is a fair chance your scanner is running the closest older profile and hasn’t mentioned it. The result is still useful, but that caveat belongs in the first line of anything you hand to an auditor, and there is a quick way to check, which we’ll get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two scanners, two views of the cluster
&lt;/h2&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;kube-bench&lt;/strong&gt; runs on a node. It reads config files, file permissions and the flags on running processes, then compares them with the CIS checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubescape&lt;/strong&gt; talks to the API server. It evaluates the resources in your cluster, or the manifests in your repo, against frameworks such as NSA-CISA, MITRE ATT&amp;amp;CK and CIS.&lt;/li&gt;
&lt;li&gt;They overlap on the policy-style checks, and that is about it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why “kube-bench or Kubescape?” is the wrong question. Run only kube-bench and you get a point-in-time audit with no idea what changed last Tuesday. Run only the Kubescape CLI and you miss everything that depends on a file mode or a kubelet flag on a real node.&lt;/p&gt;

&lt;p&gt;There is also one deliciously awkward detail to get out early. The standard kube-bench Job sets &lt;code&gt;hostPID: true&lt;/code&gt; and mounts host paths such as &lt;code&gt;/etc/kubernetes&lt;/code&gt; and &lt;code&gt;/var/lib/kubelet&lt;/code&gt;, so the compliance tool needs an exemption from the restricted pod policy it is about to tell you to enforce. A host-level audit has to see the host, so plan for the exemption now rather than discovering it when admission rejects the pod.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the CIS Kubernetes Benchmark is, and what it is not
&lt;/h2&gt;

&lt;p&gt;The benchmark is a hardening checklist for cluster configuration. Its sections cover the control plane components, etcd, control plane configuration, worker nodes, and policies: RBAC, pod security, network policies and secrets. Every recommendation comes with an audit procedure and a remediation, which is what makes it automatable.&lt;/p&gt;

&lt;p&gt;What it leaves out is where people over-read the score. A good CIS result tells you the configuration is in better shape than it was. It does &lt;em&gt;not&lt;/em&gt; tell you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether workloads behave safely at runtime&lt;/li&gt;
&lt;li&gt;whether your images are full of known CVEs&lt;/li&gt;
&lt;li&gt;whether your admission policies are broad enough for your actual risk model&lt;/li&gt;
&lt;li&gt;whether the control plane you rent from a provider was set up the way you would have done it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So treat the benchmark the way you would treat a good linter: very useful, occasionally annoying, and dangerous only when somebody mistakes it for a complete security strategy. If a score is going in front of leadership, put that caveat on the same slide. “100% CIS” and “secure” are different claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with kube-bench, on the nodes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/aquasecurity/kube-bench" rel="noopener noreferrer"&gt;kube-bench&lt;/a&gt; is an open-source project from Aqua Security, and it does one blunt thing well: read the files and flags that exist on the host and compare them with the benchmark profile it knows about.&lt;/p&gt;

&lt;p&gt;The normal entry point is the &lt;code&gt;Job&lt;/code&gt; manifest in the root of the repo. On a self-managed cluster, apply it and read the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs job/kube-bench
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every check comes back in one of four states. &lt;code&gt;PASS&lt;/code&gt; and &lt;code&gt;FAIL&lt;/code&gt; are what they sound like. &lt;code&gt;WARN&lt;/code&gt; means kube-bench could not make the call for you, either because the benchmark marks the check as manual or because the check is unscored. &lt;code&gt;INFO&lt;/code&gt; is a section header or a check you skipped. The habit to avoid is reading &lt;code&gt;WARN&lt;/code&gt; as “fine”. In the vanilla profile nothing in the policies section is scored, so nothing in it can ever show as &lt;code&gt;FAIL&lt;/code&gt;, and that section holds the RBAC and pod security checks you probably care about most.&lt;/p&gt;

&lt;p&gt;The Job needs &lt;code&gt;hostPID&lt;/code&gt; and that stack of read-only host mounts because kube-bench is looking at the node directly rather than asking the API server to summarise it. You can see the same assumption in the container one-liner from the &lt;a href="https://github.com/aquasecurity/kube-bench/blob/main/docs/running.md" rel="noopener noreferrer"&gt;upstream docs&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /etc:/etc:ro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /var:/var:ro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; docker.io/aquasec/kube-bench:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is how it catches things an API-only scanner cannot. It is also why the Job belongs in its own clearly named namespace, labelled &lt;code&gt;privileged&lt;/code&gt; for &lt;a href="https://dev.to/blog/podsecuritypolicy-replacement-pod-security-admission"&gt;Pod Security Admission&lt;/a&gt;, rather than squeezed under your strictest workload policy. One more practical note: to audit control-plane nodes the pod has to be scheduled onto them, which means adding a &lt;code&gt;nodeSelector&lt;/code&gt; and tolerations to the manifest first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark lag is normal, so check which profile ran
&lt;/h2&gt;

&lt;p&gt;The easiest way to confuse yourself is to assume the latest kube-bench means the latest CIS revision. kube-bench maps your Kubernetes version to a benchmark profile using a table in its config, and the &lt;a href="https://github.com/aquasecurity/kube-bench/blob/main/docs/platforms.md" rel="noopener noreferrer"&gt;platforms page&lt;/a&gt; in the docs shows the same mapping. When your cluster is newer than anything in that table, kube-bench walks the minor version down until it finds a match and runs that profile instead. There is no error, and at the default log level there is no warning either.&lt;/p&gt;

&lt;p&gt;The JSON output is where you catch it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kube-bench run &lt;span class="nt"&gt;--targets&lt;/span&gt; node,policies &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each block carries a &lt;code&gt;version&lt;/code&gt; field, which is the profile that ran, and a &lt;code&gt;detected_version&lt;/code&gt; field, which is the Kubernetes version kube-bench found. Compare the pair with the platforms table before you write policy around the result.&lt;/p&gt;

&lt;p&gt;Once you know which profile you want, pin it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kube-bench run &lt;span class="nt"&gt;--benchmark&lt;/span&gt; &amp;lt;profile&amp;gt; &lt;span class="nt"&gt;--targets&lt;/span&gt; node,policies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;&amp;lt;profile&amp;gt;&lt;/code&gt; is a name from the “kube-bench config” column of that table. Pinning can’t give you a newer benchmark than the tool ships. What it buys is a result you can describe accurately, and a scheduled run that won’t switch profile underneath you on the day somebody bumps the image tag.&lt;/p&gt;

&lt;p&gt;The same table matters if you are not on vanilla Kubernetes. k3s, RKE2, OpenShift and the managed services each have their own profiles, because the vanilla one looks in paths those platforms do not use and rewards you with a page of false failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  One finding, end to end
&lt;/h2&gt;

&lt;p&gt;Reports are easy to admire from a distance, so take one finding all the way through. In the current vanilla profiles, check &lt;code&gt;5.2.3&lt;/code&gt; is “Minimize the admission of containers wishing to share the host process ID namespace”. Four questions get you from the line in the report to a decision:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which check fired, and in which profile?&lt;/strong&gt; IDs move between profiles. The same control is &lt;code&gt;4.2.2&lt;/code&gt; in the EKS profile, which is one more reason to pin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What did kube-bench run to decide?&lt;/strong&gt; For this check, a &lt;code&gt;kubectl&lt;/code&gt; loop over every pod in every namespace that reads &lt;code&gt;.spec.hostPID&lt;/code&gt;. It reports pods that share the host PID namespace today, and never asks whether a policy would stop the next one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is this a real gap, an intentional exception, or the platform’s business?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What is the narrowest fix that changes the risk, not just the score?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first answer matters more than it looks. The vanilla profile treats this check as unscored, so a failure prints as &lt;code&gt;WARN&lt;/code&gt;. The EKS profile treats it as automated and scored, so the same pods print as &lt;code&gt;FAIL&lt;/code&gt;. The headline changes with the profile; the risk does not.&lt;/p&gt;

&lt;p&gt;The benchmark’s remediation text is “Add policies to each namespace in the cluster which has user workloads to restrict the admission of &lt;code&gt;hostPID&lt;/code&gt; containers”, and on a current cluster the policy in question is a Pod Security Admission label. The &lt;code&gt;baseline&lt;/code&gt; level already forbids host namespaces, so you do not need &lt;code&gt;restricted&lt;/code&gt; to clear this finding. Ask the API server what would break before you enforce anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl label &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;server &lt;span class="nt"&gt;--overwrite&lt;/span&gt; ns &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/enforce&lt;span class="o"&gt;=&lt;/span&gt;baseline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A server-side dry run changes nothing, and it prints a warning for every namespace with pods that would violate the level (any &lt;code&gt;baseline&lt;/code&gt; violation, so host networking and &lt;code&gt;hostPath&lt;/code&gt; volumes show up too). The namespaces it names are your candidate exceptions: usually a node exporter, a logging or security agent, and kube-bench itself. From there the fix is a split model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep most namespaces on &lt;code&gt;baseline&lt;/code&gt; or &lt;code&gt;restricted&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;carve out one clearly named namespace for infrastructure that needs host access&lt;/li&gt;
&lt;li&gt;write down why that namespace exists and who can deploy into it&lt;/li&gt;
&lt;li&gt;re-run only the relevant check, so you know you changed the right thing
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kube-bench run &lt;span class="nt"&gt;--targets&lt;/span&gt; policies &lt;span class="nt"&gt;--check&lt;/span&gt; 5.2.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect the check to stay at &lt;code&gt;WARN&lt;/code&gt;, because the pods in your exception namespace still have &lt;code&gt;hostPID: true&lt;/code&gt;. That is correct. What has changed is that you can now explain every pod behind it, and an explained exception is what an auditor is looking for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managed clusters: part of the benchmark is out of reach
&lt;/h2&gt;

&lt;p&gt;The kube-bench docs are direct about this: “It is impossible to inspect the master nodes of managed clusters, e.g. GKE, EKS, AKS and ACK”. That sentence should change how you describe the result. On a managed control plane, you are auditing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the worker nodes you can reach&lt;/li&gt;
&lt;li&gt;the policy and workload checks that are visible from inside the cluster&lt;/li&gt;
&lt;li&gt;whatever managed-service profile kube-bench ships for your platform&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project ships separate manifests such as &lt;code&gt;job-eks.yaml&lt;/code&gt; and &lt;code&gt;job-gke.yaml&lt;/code&gt; for exactly this reason. Running kube-bench on EKS, for example, you cannot schedule a pod onto the control-plane nodes, so the master checks are simply unavailable.&lt;/p&gt;

&lt;p&gt;Your compliance story on a managed platform is therefore partly technical and partly contractual. You verify what you can, and for the control plane you lean on the provider’s documentation, attestations and shared-responsibility model. Say so in the report: “out of our reach, covered by the provider’s attestation” is accurate, and easier to defend than a suspiciously complete set of green ticks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubescape for the part that keeps running
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/kubescape/kubescape" rel="noopener noreferrer"&gt;Kubescape&lt;/a&gt; is a &lt;a href="https://www.cncf.io/projects/kubescape/" rel="noopener noreferrer"&gt;CNCF project&lt;/a&gt; and it now covers a lot more than compliance: misconfiguration scanning, image scanning, admission control and runtime detection all live under the same name. The slice that matters here is narrower: the API-side posture layer that sits next to kube-bench.&lt;/p&gt;

&lt;p&gt;The CLI is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubescape list frameworks
kubescape scan framework nsa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run them in that order. The CIS framework IDs have the benchmark revision baked into the name, so an ID copied from an old tutorial may no longer exist in the version you installed. &lt;code&gt;nsa&lt;/code&gt; and &lt;code&gt;mitre&lt;/code&gt; are stable names; for CIS, list first and use what is actually there.&lt;/p&gt;

&lt;p&gt;For CI or a recurring check, the threshold flag is the practical one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubescape scan framework nsa &lt;span class="nt"&gt;--compliance-threshold&lt;/span&gt; 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below the score you set, the command exits with code 1. That is usually enough to stop “we should probably look at this later” from becoming a permanent state. Pick the number from a real baseline run, though. A threshold the cluster can’t currently meet tends to get switched off rather than fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubescape vs kube-bench: which question goes where
&lt;/h2&gt;

&lt;p&gt;Reach for &lt;strong&gt;kube-bench&lt;/strong&gt; when the question is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;are kubelet and control-plane settings aligned with the benchmark?&lt;/li&gt;
&lt;li&gt;do file permissions and host-level configuration match the guidance?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reach for &lt;strong&gt;Kubescape&lt;/strong&gt; when the question is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what is the posture of the workloads and resources in this cluster right now?&lt;/li&gt;
&lt;li&gt;can I run this in CI against manifests, before anything reaches a cluster?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The numbers won’t line up, because the two tools count different things. Where they do overlap, use it as a cross-check: if both are unhappy about the same class of policy gap, that finding goes to the top of the pile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The in-cluster path is where Kubescape starts paying for itself
&lt;/h2&gt;

&lt;p&gt;The Kubescape operator is the natural next step once you are past one-off CLI scans, and the install is refreshingly ordinary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add kubescape https://kubescape.github.io/helm-charts/
helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; kubescape kubescape/kubescape-operator &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; kubescape &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in the &lt;a href="https://github.com/kubescape/helm-charts/blob/main/charts/kubescape-operator/values.yaml" rel="noopener noreferrer"&gt;chart’s values&lt;/a&gt; are easy to miss. Out of the box, configuration scans run on a schedule; the change-driven rescans that most people picture when they hear “continuous” sit behind &lt;code&gt;capabilities.continuousScan&lt;/code&gt;, which is disabled by default. And the operator’s node agent mounts the host filesystem read-only, so the &lt;code&gt;kubescape&lt;/code&gt; namespace needs the same Pod Security exception as kube-bench. Your one documented exceptions namespace becomes two, which is still a list short enough to defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal recurring setup, and where to start
&lt;/h2&gt;

&lt;p&gt;If you want the smallest loop that is still worth having, I would keep it to this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;kube-bench on a schedule&lt;/strong&gt;, as a &lt;code&gt;CronJob&lt;/code&gt; with the profile pinned, for the host-level checks only it can see&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubescape in CI or in-cluster&lt;/strong&gt; for the API-side posture checks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;one threshold that fails something real&lt;/strong&gt;, instead of a dashboard with no owner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;one exception path&lt;/strong&gt; for justified infrastructure privileges, documented and reviewed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As for where to start: run the kube-bench Job once, and before you fix anything, sort every &lt;code&gt;FAIL&lt;/code&gt; and &lt;code&gt;WARN&lt;/code&gt; into three piles: real fixes, documented exceptions, and the provider’s side of the line. That sorted list is a more useful deliverable than a score, and it is what the recurring scans then keep current.&lt;/p&gt;

</description>
      <category>security</category>
      <category>kubernetes</category>
      <category>cisbenchmark</category>
      <category>compliance</category>
    </item>
    <item>
      <title>Kubernetes is not a platform: it is a toolkit</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Wed, 09 Sep 2026 09:40:15 +0000</pubDate>
      <link>https://dev.to/coresolutions/kubernetes-is-not-a-platform-it-is-a-toolkit-16e2</link>
      <guid>https://dev.to/coresolutions/kubernetes-is-not-a-platform-it-is-a-toolkit-16e2</guid>
      <description>&lt;p&gt;A team says it has “built a platform” because it runs Kubernetes. Then a developer tries to ship a service and still has to assemble a &lt;code&gt;Deployment&lt;/code&gt;, &lt;code&gt;Service&lt;/code&gt;, &lt;code&gt;HorizontalPodAutoscaler&lt;/code&gt;, &lt;code&gt;NetworkPolicy&lt;/code&gt;, &lt;code&gt;PodDisruptionBudget&lt;/code&gt;, &lt;code&gt;ServiceAccount&lt;/code&gt;, and half a dozen bits of environment-specific glue by hand. That is the category error in one scene. &lt;strong&gt;Kubernetes is the substrate. The platform is still on the backlog.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Kubernetes project makes the same point in its &lt;a href="https://kubernetes.io/docs/concepts/overview/" rel="noopener noreferrer"&gt;own overview docs&lt;/a&gt;. Under the heading “What Kubernetes is not”, it says Kubernetes is “not a traditional, all-inclusive PaaS” and that it provides “the building blocks for building developer platforms”. Building blocks, from the people who maintain them. So when you hand product teams raw &lt;code&gt;kubectl&lt;/code&gt;, raw YAML and a wiki full of tribal knowledge, what you have handed over is a very capable control plane and a pile of homework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes is a very good substrate
&lt;/h2&gt;

&lt;p&gt;Kubernetes earns its place in all this. It gives you strong primitives and a programmable control plane, and that combination is hard to come by anywhere else.&lt;/p&gt;

&lt;p&gt;Concretely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;declarative desired state&lt;/li&gt;
&lt;li&gt;reconciliation loops&lt;/li&gt;
&lt;li&gt;scheduling and placement&lt;/li&gt;
&lt;li&gt;service discovery&lt;/li&gt;
&lt;li&gt;rollout machinery&lt;/li&gt;
&lt;li&gt;extensibility through CRDs and controllers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which is a serious foundation, and it explains why Kubernetes keeps ending up in the middle of internal platforms.&lt;/p&gt;

&lt;p&gt;Foundations and finished products sit in different categories, though. Postgres is a foundation; the finance dashboard someone logs into every morning is the product built on it. A cluster running important workloads sits on the foundation side of that line, however well run it is.&lt;/p&gt;

&lt;p&gt;This is where a lot of platform work goes sideways. Infrastructure teams do the hard bit: secure the cluster, patch it, make it observable, get some governance around it. Somewhere in the following six months the organisation starts calling the cluster “the platform”, on the assumption that a working substrate produces a usable product on its own. It never has.&lt;/p&gt;

&lt;h2&gt;
  
  
  A platform is a product with users
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://tag-app-delivery.cncf.io/whitepapers/platforms/" rel="noopener noreferrer"&gt;CNCF Platforms white paper&lt;/a&gt; uses much stricter language than most teams do in everyday conversation. It defines a platform as an integrated collection of capabilities &lt;strong&gt;defined and presented according to the needs of the platform’s users&lt;/strong&gt;, and it calls out interfaces such as web portals, project templates, and self-service APIs.&lt;/p&gt;

&lt;p&gt;“We run Kubernetes” clears none of that bar. The platform is the opinionated layer above it, the one that turns raw technology into something people can use safely and repeatedly.&lt;/p&gt;

&lt;p&gt;Usually that means some combination of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;self-service interfaces&lt;/strong&gt; so teams can create or change what they need without waiting on tickets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;paved roads&lt;/strong&gt; so the safe path is the easy path&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;policy and defaults&lt;/strong&gt; so teams do not need to rediscover every security and reliability lesson themselves&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;documentation and examples&lt;/strong&gt; that reflect how the organisation actually expects software to be shipped&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;support boundaries&lt;/strong&gt; so people know what is standard, what is allowed, and what will get help at 02:00&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one does more work than it looks. A platform is partly software and partly promises. An interface where no one can tell you the support status is a hopeful abstraction: teams route around it the first time it surprises them, and now you maintain two paths instead of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The category error shows up as cognitive rent
&lt;/h2&gt;

&lt;p&gt;When teams say “Kubernetes is our platform”, they usually mean “Kubernetes is where our applications run”. The second claim is easy to earn; the first one is a product commitment.&lt;/p&gt;

&lt;p&gt;There is a test for which one you have: &lt;strong&gt;what does a product engineer still need to understand to ship a normal service safely?&lt;/strong&gt; If the answer runs to “quite a lot of Kubernetes”, you are still handing over the substrate.&lt;/p&gt;

&lt;p&gt;That usually means developers still need working knowledge of things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how requests and limits interact with scheduling&lt;/li&gt;
&lt;li&gt;when an &lt;code&gt;HPA&lt;/code&gt; will or will not behave the way they expect&lt;/li&gt;
&lt;li&gt;why a missing &lt;code&gt;PodDisruptionBudget&lt;/code&gt; only hurts during maintenance windows&lt;/li&gt;
&lt;li&gt;how &lt;code&gt;NetworkPolicy&lt;/code&gt; defaults work in their cluster&lt;/li&gt;
&lt;li&gt;which ingress, gateway, or certificate path is considered standard&lt;/li&gt;
&lt;li&gt;which bits of YAML are mandatory because the platform has no stronger abstraction yet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Developers learning infrastructure is fine, and plenty of them should. The trouble starts when shipping an ordinary service requires them to learn the platform team’s entire problem space.&lt;/p&gt;

&lt;p&gt;That is the bill for confusing a toolkit with a platform. The complexity stays exactly where it was; it just gets paid for by every team that touches it, in small instalments, every time they deploy.&lt;/p&gt;

&lt;p&gt;This is why the Team Topologies framing still holds up. A platform is valuable in proportion to the cognitive load it takes off the teams consuming it. Ask developers to become part-time Kubernetes operators and you have moved that load rather than absorbed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A thin platform still counts
&lt;/h2&gt;

&lt;p&gt;One reason teams overstate what Kubernetes gives them is that they imagine the alternative must be some giant internal developer portal project. If they are not ready to build Backstage templates, service catalogs, policy packs, and self-service workflows, they decide the cluster itself must count as the platform. Most of the useful work lives in the gap between those two options.&lt;/p&gt;

&lt;p&gt;Team Topologies has a saner idea: the &lt;strong&gt;thinnest viable platform&lt;/strong&gt;. On the Team Topologies TVP page, Matthew Skelton says that this can be “just a wiki page” if that is all you need at your current scale. That line keeps the argument grounded. What qualifies something as a platform is whether it gives your developers a clearer, safer, more repeatable way to get work done. Scale decides how thick it has to be; nothing else does.&lt;/p&gt;

&lt;p&gt;Sometimes the thinnest useful platform is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one golden path for a standard web service&lt;/li&gt;
&lt;li&gt;a starter template with the right defaults already wired in&lt;/li&gt;
&lt;li&gt;one supported CI path&lt;/li&gt;
&lt;li&gt;one documented way to expose a service&lt;/li&gt;
&lt;li&gt;one place to request a database or secret with known guardrails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is still platform work, and at most organisations it is the right platform work to do first.&lt;/p&gt;

&lt;p&gt;Starting small is fine. The mistake is skipping the product layer entirely and calling the control plane beneath it finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real platform adds on top of Kubernetes
&lt;/h2&gt;

&lt;p&gt;If Kubernetes is the substrate, what does the platform layer actually contribute?&lt;/p&gt;

&lt;p&gt;At minimum, it should remove repeated decisions that your teams do not benefit from making over and over.&lt;/p&gt;

&lt;p&gt;A decent internal platform usually adds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An opinionated service shape.&lt;/strong&gt; New services start from a template or scaffold that already includes the organisation’s baseline for probes, resources, identity, networking, and delivery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A supported path to production.&lt;/strong&gt; One default route that the platform team actively maintains, and that most services can take without needing a conversation first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails with teeth.&lt;/strong&gt; Policy, admission control, CI checks, and defaults that catch the boring mistakes before they get merged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-service for common needs.&lt;/strong&gt; Databases, secrets, DNS, certificates, queues, environments, or delivery workflows that do not require a human to hand-hold every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A product experience.&lt;/strong&gt; Good docs, clear ownership, visible support boundaries, and feedback loops so the thing improves instead of calcifying.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what is missing from that list: “owning a cluster”. Owning a cluster is an operational capability, and platform engineering usually needs one. It buys you the right to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the maturity model as a self-check
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://tag-app-delivery.cncf.io/whitepapers/platform-eng-maturity-model/" rel="noopener noreferrer"&gt;CNCF Platform Engineering Maturity Model&lt;/a&gt; is useful here because it stops the conversation from collapsing into slogans.&lt;/p&gt;

&lt;p&gt;It describes &lt;strong&gt;four levels&lt;/strong&gt; — &lt;code&gt;Provisional&lt;/code&gt;, &lt;code&gt;Operationalized&lt;/code&gt;, &lt;code&gt;Scalable&lt;/code&gt;, and &lt;code&gt;Optimizing&lt;/code&gt; — and looks across &lt;strong&gt;five aspects&lt;/strong&gt;: investment, adoption, interfaces, operations, and measurement.&lt;/p&gt;

&lt;p&gt;That works better as a diagnostic than asking whether you “have a platform”. Almost every team is somewhere in the middle of that grid, and the useful question is which cell they are in and which one they want to be in next.&lt;/p&gt;

&lt;p&gt;A few blunt checks help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interfaces:&lt;/strong&gt; do teams have a real self-service interface, or are they still stitching together YAML and Slack messages?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adoption:&lt;/strong&gt; do product teams voluntarily use the paved road because it is better, or because they were told to?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operations:&lt;/strong&gt; is there a supported day-two story behind the abstraction, or only a shiny day-one story?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measurement:&lt;/strong&gt; can you show that the platform shortens delivery loops or reduces support load, or is that still mostly assumed?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Investment:&lt;/strong&gt; is there sustained ownership, or is the “platform” just a side project orbiting the cluster team?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Answer those five properly and the picture resolves fast. A team with a well-run cluster, ad-hoc interfaces and adoption it has never measured is doing valuable infrastructure work. The platform sits a column to the right of where that team is standing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the finish line actually is
&lt;/h2&gt;

&lt;p&gt;The finish line is a state where most teams can ship a service without writing a production-grade manifest from scratch.&lt;/p&gt;

&lt;p&gt;You still want depth somewhere. Strong operational understanding in the platform team, and enough shared literacy that developers can reason about what runs underneath their code — that is a real asset, and it is a different thing from requiring it of everyone before they are allowed to deploy.&lt;/p&gt;

&lt;p&gt;Developers should be able to use the platform the way you hope customers use any good product — confidently, and without carrying a mental model of its internals around with them.&lt;/p&gt;

&lt;p&gt;Calling the substrate the platform has a specific cost. It lets you declare victory early, and after that every improvement gets spent on the cluster rather than on the people using it.&lt;/p&gt;

&lt;p&gt;If you want one thing to do this quarter: take the service shape your teams create most often and give it a single supported path — a template, a sane set of defaults, one documented route to production. Then time how long it takes somebody outside the platform team to get from an empty repository to a running service, before and after. If that number holds steady, you built the abstraction for your own convenience.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>platformengineering</category>
      <category>devrel</category>
      <category>internaldeveloperplatform</category>
    </item>
    <item>
      <title>Helm charts are technical debt: blame the templating model</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Mon, 31 Aug 2026 08:03:53 +0000</pubDate>
      <link>https://dev.to/coresolutions/helm-charts-are-technical-debt-blame-the-templating-model-19g9</link>
      <guid>https://dev.to/coresolutions/helm-charts-are-technical-debt-blame-the-templating-model-19g9</guid>
      <description>&lt;p&gt;The pull request is four lines of template, adding an optional &lt;code&gt;nodeSelector&lt;/code&gt; to an internal chart. CI has already done its job properly: &lt;code&gt;helm lint&lt;/code&gt; passed, the chart rendered against every environment's values file, &lt;code&gt;kubeconform&lt;/code&gt; validated each manifest that came out, and the snapshot diff of the rendered output is sitting in the check run waiting to be read.&lt;/p&gt;

&lt;p&gt;And you still cannot say, from the diff, what this change does in staging without going and reading three other templates first.&lt;/p&gt;

&lt;p&gt;Nobody files that as technical debt. It goes in the mental folder marked “charts are a bit fiddly”. But if your team maintains a dozen first-party charts, that tax gets levied on every review, and no amount of pipeline pays it off, because the pipeline was never the thing that was broken.&lt;/p&gt;

&lt;p&gt;This is not a “Helm was always bad” post. Helm solved a real problem, and most teams running Kubernetes are better off with it than without it. The rot is somewhere more specific: &lt;strong&gt;Helm renders Kubernetes manifests by running Go's &lt;code&gt;text/template&lt;/code&gt; over YAML as strings, and once you are authoring charts rather than just installing them, that model generates work forever.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That model has survived every major version so far, Helm 4 included. If your chart estate already feels brittle, the next release will not make it less so, and I will come back to why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Helm won for good reasons
&lt;/h2&gt;

&lt;p&gt;Helm is a CNCF graduated project and the default packaging story in Kubernetes, and it deserves to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;install, upgrade, rollback and release history in a single tool&lt;/li&gt;
&lt;li&gt;a standard way for vendors and open-source projects to ship applications&lt;/li&gt;
&lt;li&gt;OCI registry support that slots into existing GitOps workflows&lt;/li&gt;
&lt;li&gt;ecosystem gravity, which by now matters nearly as much as technical merit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nobody sensible is still having the “should we use Helm at all?” argument. If you are installing Grafana, or &lt;a href="https://dev.to/blog/secrets-management-scale-external-secrets-operator"&gt;External Secrets Operator&lt;/a&gt;, or almost anything with a healthy community behind it, the chart is the path of least resistance and you should take it.&lt;/p&gt;

&lt;p&gt;The trouble is that the same tool that consumes packaged software so well also makes it very easy to start writing your own. One chart for the internal API. One for the batch worker. Then a shared library chart, because three of them had grown the same &lt;code&gt;_helpers.tpl&lt;/code&gt;. At some point you stopped being a Helm user and became the maintainer of a templating system. That is a perfectly reasonable thing to be, as long as it was a decision rather than a drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The debt is in the rendering model
&lt;/h2&gt;

&lt;p&gt;Helm templates know nothing about Kubernetes objects. They emit text that happens to be YAML.&lt;/p&gt;

&lt;p&gt;Here is the shape of it, lifted from roughly every &lt;code&gt;deployment.yaml&lt;/code&gt; in every internal chart:&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;# templates/deployment.yaml&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;template&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;- with .Values.nodeSelector&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
      &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;- toYaml . | nindent 8&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="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="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;image&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.image.repository&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;:{{ .Values.image.tag }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;8&lt;/code&gt; is load-bearing. &lt;code&gt;nodeSelector:&lt;/code&gt; sits at column six, so its keys have to land at column eight, and &lt;a href="https://helm.sh/docs/chart_template_guide/function_list/" rel="noopener noreferrer"&gt;&lt;code&gt;nindent&lt;/code&gt;&lt;/a&gt; exists precisely because chart authors need to re-indent embedded blocks constantly. Write &lt;code&gt;nindent 6&lt;/code&gt; by mistake and you have not made a formatting error. You have set &lt;code&gt;nodeSelector&lt;/code&gt; to null and promoted its contents to siblings of &lt;code&gt;containers&lt;/code&gt;, and the render will print that quite happily, because it is still a well-formed YAML document. Nothing in the YAML layer objects. It takes schema validation downstream, &lt;code&gt;kubeconform -strict&lt;/code&gt; in the pipeline or a server-side dry run, before anything flags it. That is a lot of machinery to need in order to catch a two-space mistake.&lt;/p&gt;

&lt;p&gt;Two spaces. Helm's own &lt;a href="https://helm.sh/docs/chart_best_practices/templates/" rel="noopener noreferrer"&gt;template best practices&lt;/a&gt; page walks through indentation, whitespace chomping and generated-output formatting, and concedes the underlying problem in passing: “YAML is a whitespace-oriented language.” Full marks for honesty, but that is a peculiar property for an abstraction layer to have.&lt;/p&gt;

&lt;p&gt;All of which is the cheap version of the problem, and it is cheap precisely because a machine can express it. Schema validation catches a wrong shape, and it catches it in seconds. What no pipeline catches is a chart whose &lt;em&gt;right&lt;/em&gt; shapes have become impossible to hold in your head.&lt;/p&gt;

&lt;p&gt;Once your abstraction works on strings rather than typed objects, a few things follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;indentation becomes part of correctness, not just tidiness&lt;/li&gt;
&lt;li&gt;a branch that renders the wrong shape still looks plausible in review&lt;/li&gt;
&lt;li&gt;values become an API surface long before anyone has designed them like one&lt;/li&gt;
&lt;li&gt;refactoring is hard, because the behaviour lives across helpers, partials and value conventions rather than in a schema your tooling understands&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Disciplined teams are not exempt from this. They often accumulate &lt;em&gt;more&lt;/em&gt; chart machinery, because they build helpers and layering and conventions to contain the complexity. That containment is genuinely useful, and it is also the tell: you are building scaffolding around a model that produces complexity faster than you can absorb it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it shows up on day two
&lt;/h2&gt;

&lt;p&gt;You rarely spot chart debt by reading a chart. You spot it in the changes that need far more scrutiny than their diff size suggests they should.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;values.yaml&lt;/code&gt; turns into an untyped product surface
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;values.yaml&lt;/code&gt; is the front door to your chart, which is convenient right up until you realise you have shipped a long-lived API with no type system behind it.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;values.schema.json&lt;/code&gt; belongs in every first-party chart, and Helm will enforce 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;. But the &lt;a href="https://helm.sh/docs/topics/charts/#schema-files" rel="noopener noreferrer"&gt;schema file is optional&lt;/a&gt;, so it is opt-in per chart and easy to leave behind on the one chart nobody has revisited, and it describes structure rather than behaviour. A value can be structurally perfect and still produce nonsense once it has threaded its way through conditionals, helper templates, defaults and subchart overrides.&lt;/p&gt;

&lt;p&gt;Then another team starts depending on a value name. Renaming a field stops being a cleanup and becomes a migration, complete with a deprecation window and a note in the release notes that nobody reads.&lt;/p&gt;

&lt;h3&gt;
  
  
  The source is harder to review than the output
&lt;/h3&gt;

&lt;p&gt;A rendered manifest can be entirely ordinary while the logic that produced it is anything but.&lt;/p&gt;

&lt;p&gt;Humans review the chart source. A pull request that flips a condition or threads one more flag through three templates is not asking “is this &lt;code&gt;Deployment&lt;/code&gt; valid?”. It is asking “which combinations of values, defaults and includes now produce a different document?”, and that question has no diff you can look at.&lt;/p&gt;

&lt;p&gt;Which is why the gates all end up pointed at the rendered output rather than the source. Those gates are worth building, and I will come back to ours. They are also a fair signal about the model: you test the output because nobody can reliably reason about the input.&lt;/p&gt;

&lt;h3&gt;
  
  
  Subcharts and value plumbing sprawl quietly
&lt;/h3&gt;

&lt;p&gt;Small Helm setups look tidy. Then the platform grows, one chart wraps five subcharts, globals appear, child values get overridden from the parent, and a flag in one place changes behaviour three templates away.&lt;/p&gt;

&lt;p&gt;Some of those values exist because users asked for them. Some exist because a subchart expects them. Some exist because somebody needed a workaround two years ago and nobody has ever felt brave enough to delete it. That is the point where a chart stops being a packaging unit and starts being institutional memory encoded as YAML conventions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Helm 4 improved Helm without changing this
&lt;/h2&gt;

&lt;p&gt;This is where lazy anti-Helm takes get sloppy, so let me be precise about it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/helm/helm/releases/tag/v4.0.0" rel="noopener noreferrer"&gt;Helm 4.0.0&lt;/a&gt; shipped in November 2025 and it is a genuine improvement: server-side apply, a redesigned plugin system with WebAssembly plugins, resource watching built on &lt;code&gt;kstatus&lt;/code&gt;, post-renderers as plugins, reproducible chart archives. If you run Helm heavily, upgrade.&lt;/p&gt;

&lt;p&gt;All of that is release machinery, though. Rendering still goes through Go's text templating, &lt;code&gt;values.yaml&lt;/code&gt; is still the primary interface, and the combinatorial growth of flags and helpers in your own charts remains entirely your problem. Helm 4 made Helm better at the job Helm is good at. Chart authoring was never on the list.&lt;/p&gt;

&lt;p&gt;A version bump on its own will not retire this argument, so here is what would: a release that swaps text templating for a typed object model, or one that makes schema validation mandatory instead of opt-in. If you are reading this a few releases later, that is the thing to go and check. Short of it, the version numbers in this post move and the point does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the alternatives actually change
&lt;/h2&gt;

&lt;p&gt;Different tools replace different &lt;em&gt;parts&lt;/em&gt; of Helm, which is where most “Helm alternatives” posts go wrong. There is no swap that hands you better authoring, better packaging, better release management and better ecosystem compatibility all at once.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it replaces&lt;/th&gt;
&lt;th&gt;What you still need&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kustomize&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Templating, for the overlay case&lt;/td&gt;
&lt;td&gt;Packaging, versioning, release history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Timoni&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The authoring model, with CUE typing&lt;/td&gt;
&lt;td&gt;Ecosystem gravity, and patience while it is pre-1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;cdk8s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Templating, with a real programming language&lt;/td&gt;
&lt;td&gt;Packaging, plus a language runtime you now own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Jsonnet / Tanka&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Templating, with a compact composition syntax&lt;/td&gt;
&lt;td&gt;Types, and a way to stop dynamic behaviour relocating complexity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KCL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Templating, with a typed config language&lt;/td&gt;
&lt;td&gt;Ecosystem gravity, and a team willing to learn a new language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Score&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The developer-facing values surface&lt;/td&gt;
&lt;td&gt;A platform implementation underneath that satisfies the spec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://kubectl.docs.kubernetes.io/references/kustomize/" rel="noopener noreferrer"&gt;Kustomize&lt;/a&gt; is the one I reach for most often outside Helm, largely because it already ships inside &lt;code&gt;kubectl&lt;/code&gt; and patches structured YAML instead of templating arbitrary strings. If your real problem is “I have plain manifests and need per-environment overlays”, it removes a surprising amount of cleverness for almost no adoption cost. Expecting it to do packaging too is how people end up disappointed by it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://timoni.sh/" rel="noopener noreferrer"&gt;Timoni&lt;/a&gt; is the most interesting answer to “what if we fixed the authoring model?”. CUE, real typing, modules distributed as OCI artifacts: conceptually most of what people are asking for. The caveat is maturity, and it is still on its 0.x line (&lt;code&gt;v0.29.0&lt;/code&gt; as I write this), so go and check where it has got to before committing. A 1.0 would change the calculation considerably more than another point release will.&lt;/p&gt;

&lt;p&gt;The code-generation family trades typing for operational surface. &lt;a href="https://www.cncf.io/projects/cdk-for-kubernetes-cdk8s/" rel="noopener noreferrer"&gt;cdk8s&lt;/a&gt; (joined the CNCF Sandbox in 2020) gives you real programming languages, Jsonnet with Tanka gives you a compact composition language, and &lt;a href="https://www.cncf.io/projects/kcl/" rel="noopener noreferrer"&gt;KCL&lt;/a&gt; (joined in September 2023) gives you an explicitly typed configuration language. In exchange you own a language runtime, a build step, and an onboarding cost for everyone who ever touches a manifest. Good trade for a platform team with a large estate, poor trade for a team with six charts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.cncf.io/projects/score/" rel="noopener noreferrer"&gt;Score&lt;/a&gt; (joined in July 2024) sits one layer up. It is a workload specification, asking “what does this application need from the platform?” rather than “how do I render manifests for every deployment case?”. If your real complaint is that developers keep being handed an ever-growing values surface to fill in, that is the layer to attack. It still assumes a platform underneath that can satisfy it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we actually run this
&lt;/h2&gt;

&lt;p&gt;We stayed on Helm, so it is only fair to say what containing the cost looks like day to day.&lt;/p&gt;

&lt;p&gt;Most of our platform components are wrapper charts, and that pattern carries a lot of the weight. &lt;code&gt;Chart.yaml&lt;/code&gt; declares a pinned dependency on the upstream chart, &lt;code&gt;Chart.lock&lt;/code&gt; freezes what actually resolved, and our own &lt;code&gt;templates/&lt;/code&gt; directory layers on what upstream does not ship: the NetworkPolicies, the PrometheusRules, the RBAC that upstream leaves to you. Renovate raises the upstream bumps, and they do not land until CI is green.&lt;/p&gt;

&lt;p&gt;Values sit in one place per chart. &lt;code&gt;values.yaml&lt;/code&gt; carries the defaults every environment shares, and a &lt;code&gt;values/&lt;/code&gt; directory holds the environment-specific overrides beside it, one file each. CI then lints the chart once against every one of those files rather than once overall, so a change that only breaks one environment cannot ride in on another's values.&lt;/p&gt;

&lt;p&gt;That catches everything Helm can be made to fail on. The quieter failure needs its own gate, because of a footgun worth internalising: Helm deep-merges maps, but it replaces lists outright. An environment file that overrides a list has to restate the entire list, so adding a component in one environment and forgetting another raises no error anywhere. Both files are valid. You just ship a shorter list to the environment nobody updated, and find out when something is missing. Nothing you can put in &lt;code&gt;values.yaml&lt;/code&gt; expresses “these lists must stay in step”, so a parity check in CI does it instead, and a red PR replaces copy-paste discipline. That is the scaffolding tax from earlier made concrete: a shell script exists because the type system does not.&lt;/p&gt;

&lt;p&gt;None of this makes the templating model typed. It makes the cost visible and bounded, which is the achievable goal rather than the ideal one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I would draw the line
&lt;/h2&gt;

&lt;p&gt;Helm is not technical debt because it exists in your stack, and authoring your own charts is not a mistake. We do both, and given the same constraints we would do both again. A third-party chart you install, version and occasionally override is packaging doing exactly the job it is good at, and a first-party chart is a reasonable way to ship your own software into a cluster where everything else already arrives as one.&lt;/p&gt;

&lt;p&gt;So I reach for Helm when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I am consuming third-party software that already ships a chart and I have no wish to become its packager&lt;/li&gt;
&lt;li&gt;I want release history, rollback and the install workflow Helm already gives me&lt;/li&gt;
&lt;li&gt;the surrounding ecosystem expects Helm artifacts, &lt;a href="https://dev.to/blog/gitops-for-kubernetes-with-argo-cd"&gt;GitOps tooling&lt;/a&gt; included&lt;/li&gt;
&lt;li&gt;I am packaging our own components for a platform where every other component already arrives as a chart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What accrues interest is the estate you build afterwards, and how much of it you let grow without a gate on it. Our largest first-party chart carries a &lt;code&gt;values.yaml&lt;/code&gt; north of 800 lines. That is not automatically a failure, but it is a number I would want any team to be able to quote about themselves, because the moment nobody can tell you what a chart's values surface costs, you have stopped managing it. Same instinct as &lt;a href="https://dev.to/blog/stop-copying-big-tech-platform-architecture"&gt;right-sizing the rest of your platform&lt;/a&gt;: the tool is fine, and the real question is whether the estate around it still fits the team.&lt;/p&gt;

&lt;p&gt;The alternatives are worth knowing even when you stay. We have stayed, because packaging gravity and release history are worth more to us today than typed authoring would be. If that trade flips, if the estate outgrows its gates or Timoni reaches 1.0 with real ecosystem behind it, the honest move is to notice rather than to defend the original choice.&lt;/p&gt;

&lt;p&gt;You do not have to become anti-Helm to admit that the charts you maintain can rot. In most Kubernetes estates that is hardly a hot take. It is just what year two looks like.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>helm</category>
      <category>kustomize</category>
    </item>
    <item>
      <title>PodSecurityPolicy replacement: Pod Security Admission</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Mon, 31 Aug 2026 07:57:40 +0000</pubDate>
      <link>https://dev.to/coresolutions/podsecuritypolicy-replacement-pod-security-admission-45i3</link>
      <guid>https://dev.to/coresolutions/podsecuritypolicy-replacement-pod-security-admission-45i3</guid>
      <description>&lt;p&gt;If you still think of Pod Security Admission as the cut-down replacement for PodSecurityPolicy, that is exactly the mindset that makes rollouts harder than they need to be. It &lt;em&gt;is&lt;/em&gt; less powerful. That is also why it is practical. You get three fixed profiles, three modes, a handful of labels, and an admission controller that is already in the API server. No custom policy language, no mutation, no controller to install, and far fewer ways to surprise yourself mid-change.&lt;/p&gt;

&lt;p&gt;The trick is to use that simplicity properly. A safe rollout is not “turn on &lt;code&gt;restricted&lt;/code&gt; everywhere and hope for the best”. It is version-pinned labels, audit and warn first, then namespace-by-namespace enforcement once you have seen what will break. Done that way, you can move from the old PSP world to a built-in control that teams will actually leave enabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replaced PodSecurityPolicy
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PodSecurityPolicy&lt;/code&gt; was deprecated in Kubernetes &lt;code&gt;v1.21&lt;/code&gt; and removed in &lt;code&gt;v1.25&lt;/code&gt;. &lt;a href="https://kubernetes.io/docs/concepts/security/pod-security-admission/" rel="noopener noreferrer"&gt;Pod Security Admission&lt;/a&gt; became generally available in the same release, which matters because there is nothing extra to deploy: if your cluster is current enough, the mechanism is already there.&lt;/p&gt;

&lt;p&gt;The model is deliberately small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Profiles&lt;/strong&gt;: &lt;code&gt;privileged&lt;/code&gt;, &lt;code&gt;baseline&lt;/code&gt;, &lt;code&gt;restricted&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modes&lt;/strong&gt;: &lt;code&gt;enforce&lt;/code&gt;, &lt;code&gt;audit&lt;/code&gt;, &lt;code&gt;warn&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version pinning&lt;/strong&gt;: &lt;code&gt;pod-security.kubernetes.io/&amp;lt;mode&amp;gt;-version&lt;/code&gt;, pinned to a Kubernetes minor version or &lt;code&gt;latest&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those labels live on namespaces:&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;# pin policy to the minor version your cluster is running&lt;/span&gt;
&lt;span class="nv"&gt;pss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl version &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.serverVersion.gitVersion'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;,2&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

kubectl label ns payments &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/enforce&lt;span class="o"&gt;=&lt;/span&gt;baseline &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/enforce-version&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pss&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That simplicity is the point. PSP mixed policy with RBAC, allowed mutation, and had enough moving parts that many teams either avoided it or never quite trusted what a change would do. PSA is narrower. It sets a floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: three profiles, three modes, one version pin
&lt;/h2&gt;

&lt;p&gt;You can think of PSA as a matrix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enforce&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rejects a non-compliant pod creation request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allows it, but records an audit annotation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;warn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allows it, but shows a warning to the caller&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the profiles are ordered from least to most strict:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;What it is for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;privileged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;System namespaces and workloads that genuinely need host access or privileged behaviour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseline&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A sensible minimum floor for ordinary application namespaces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;restricted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current pod hardening best practice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most application namespaces, the safe starting pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl label &lt;span class="nt"&gt;--overwrite&lt;/span&gt; ns payments &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/enforce&lt;span class="o"&gt;=&lt;/span&gt;baseline &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/enforce-version&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pss&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/warn&lt;span class="o"&gt;=&lt;/span&gt;restricted &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/warn-version&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pss&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/audit&lt;span class="o"&gt;=&lt;/span&gt;restricted &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/audit-version&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pss&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you a firm minimum bar in &lt;code&gt;enforce&lt;/code&gt;, while &lt;code&gt;warn&lt;/code&gt; and &lt;code&gt;audit&lt;/code&gt; show you what stands between the namespace and &lt;code&gt;restricted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The version pin is not optional bureaucracy. If you leave a namespace on &lt;code&gt;latest&lt;/code&gt;, any cluster upgrade can quietly tighten policy under your workloads. Pinning to the minor version you are actually running means policy changes happen when &lt;em&gt;you&lt;/em&gt; decide to move the label.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;restricted&lt;/code&gt; actually checks
&lt;/h2&gt;

&lt;p&gt;The easiest way to lose credibility on this topic is to hand-wave the &lt;code&gt;restricted&lt;/code&gt; profile and get one detail wrong. Several of the checklists floating around in other posts are simply false — especially around &lt;code&gt;runAsUser&lt;/code&gt; and seccomp.&lt;/p&gt;

&lt;p&gt;Here is the practical checklist, straight from the &lt;a href="https://kubernetes.io/docs/concepts/security/pod-security-standards/" rel="noopener noreferrer"&gt;Pod Security Standards&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;What &lt;code&gt;restricted&lt;/code&gt; expects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Volume types&lt;/td&gt;
&lt;td&gt;Only &lt;code&gt;configMap&lt;/code&gt;, &lt;code&gt;csi&lt;/code&gt;, &lt;code&gt;downwardAPI&lt;/code&gt;, &lt;code&gt;emptyDir&lt;/code&gt;, &lt;code&gt;ephemeral&lt;/code&gt;, &lt;code&gt;persistentVolumeClaim&lt;/code&gt;, &lt;code&gt;projected&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privilege escalation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;allowPrivilegeEscalation: false&lt;/code&gt; on every container, including init and ephemeral containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-root&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;runAsNonRoot: true&lt;/code&gt; at pod level or per container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UID&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;runAsUser&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; need to be set, but if it is, it cannot be &lt;code&gt;0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capabilities&lt;/td&gt;
&lt;td&gt;Drop &lt;code&gt;ALL&lt;/code&gt;; the only allowed add is &lt;code&gt;NET_BIND_SERVICE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seccomp&lt;/td&gt;
&lt;td&gt;Must be explicitly &lt;code&gt;RuntimeDefault&lt;/code&gt; or &lt;code&gt;Localhost&lt;/code&gt;; leaving it unset is a violation under &lt;code&gt;restricted&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those rows are where the bad checklists go wrong. &lt;code&gt;runAsUser&lt;/code&gt; is not required — the policy forbids UID &lt;code&gt;0&lt;/code&gt;, it does not make you hard-code some other UID just to satisfy the admission check. And seccomp cuts the other way: under &lt;code&gt;baseline&lt;/code&gt; an unset profile is fine, but under &lt;code&gt;restricted&lt;/code&gt; it is a violation in its own right, which is one of the reasons plain demo manifests still bounce when teams first try this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rollout that does not break production
&lt;/h2&gt;

&lt;p&gt;The safest PSA rollout starts with a dry run, not an enforcement change — the &lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/migrate-from-psp/" rel="noopener noreferrer"&gt;official migration guide&lt;/a&gt; is built around the same idea.&lt;/p&gt;

&lt;p&gt;First, test label application server-side across all namespaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl label &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;server &lt;span class="nt"&gt;--overwrite&lt;/span&gt; ns &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/enforce&lt;span class="o"&gt;=&lt;/span&gt;baseline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That does not change anything. It asks the API server what violations it would report if the label were applied.&lt;/p&gt;

&lt;p&gt;Then stage &lt;code&gt;audit&lt;/code&gt; and &lt;code&gt;warn&lt;/code&gt; cluster-wide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl label &lt;span class="nt"&gt;--overwrite&lt;/span&gt; ns &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/audit&lt;span class="o"&gt;=&lt;/span&gt;baseline &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/warn&lt;span class="o"&gt;=&lt;/span&gt;baseline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, if your end state is more ambitious, go straight to the pattern that tends to work well in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;enforce=baseline&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;warn=restricted&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;audit=restricted&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives you signal without immediately creating an outage because some forgotten sidecar, root-running image, or hostPath mount was hiding in a namespace nobody had looked at for months.&lt;/p&gt;

&lt;p&gt;You can also find namespaces that have no PSA labels yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get ns &lt;span class="nt"&gt;--selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'!pod-security.kubernetes.io/enforce'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the warnings are clean for a namespace, pin the version and turn on &lt;code&gt;enforce&lt;/code&gt; there. Namespace by namespace is slower than one giant switch, but slower is exactly what you want when admission control is involved.&lt;/p&gt;

&lt;p&gt;While the rollout is in flight, the API server's own metrics — &lt;code&gt;pod_security_evaluations_total&lt;/code&gt;, &lt;code&gt;pod_security_errors_total&lt;/code&gt; and &lt;code&gt;pod_security_exemptions_total&lt;/code&gt; — are worth scraping. They tell you whether evaluation is happening where you think it is, and whether exemptions are starting to sprawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real rejection looks like
&lt;/h2&gt;

&lt;p&gt;The good news about PSA failures is that the error message is the fix list.&lt;/p&gt;

&lt;p&gt;Apply a plain pod into a namespace with &lt;code&gt;enforce=restricted&lt;/code&gt; and you get something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error from server (Forbidden): pods "nginx" is forbidden: violates PodSecurity "restricted:latest":
allowPrivilegeEscalation != false (container "nginx" must set securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "nginx" must set securityContext.capabilities.drop=["ALL"]),
runAsNonRoot != true (pod or container "nginx" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "nginx" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That message is unusually helpful by Kubernetes standards. Every violation names the exact field to set and the value it wants, so you can work through it like a checklist.&lt;/p&gt;

&lt;p&gt;A minimal compliant pod looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&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;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RuntimeDefault&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginxinc/nginx-unprivileged:stable&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two quiet fixes are hiding in that example: the container-level security context, and the image itself. A lot of “hello world” manifests still assume a root-running image on port &lt;code&gt;80&lt;/code&gt;, which is a fine way to discover that &lt;code&gt;restricted&lt;/code&gt; is not interested in your tutorial shortcuts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap most teams hit once: Deployments apply, pods do not
&lt;/h2&gt;

&lt;p&gt;This is the operational detail that makes or breaks a rollout plan.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;warn&lt;/code&gt; and &lt;code&gt;audit&lt;/code&gt; evaluate workload resources such as &lt;code&gt;Deployment&lt;/code&gt; pod templates. &lt;code&gt;enforce&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt;. &lt;code&gt;enforce&lt;/code&gt; applies only to the resulting pod objects.&lt;/p&gt;

&lt;p&gt;That means this can happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You apply a &lt;code&gt;Deployment&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Kubernetes accepts the &lt;code&gt;Deployment&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;ReplicaSet&lt;/code&gt; tries to create pods&lt;/li&gt;
&lt;li&gt;The pods are rejected by PSA&lt;/li&gt;
&lt;li&gt;You now have a deployment that looks “applied” but never becomes healthy&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When that happens, the signal is in the &lt;code&gt;ReplicaSet&lt;/code&gt; and the events, not in the initial &lt;code&gt;kubectl apply&lt;/code&gt; output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get rs &lt;span class="nt"&gt;-n&lt;/span&gt; payments
kubectl get events &lt;span class="nt"&gt;-n&lt;/span&gt; payments &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.metadata.creationTimestamp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you remember only one rollout lesson from this post, make it this one. Admission on pod creation and admission on workload templates are not symmetrical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The namespaces that should not be &lt;code&gt;restricted&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;kube-system&lt;/code&gt; is the obvious example. CNIs, CSIs, node agents, and static pods routinely need things that &lt;code&gt;baseline&lt;/code&gt; or &lt;code&gt;restricted&lt;/code&gt; will reject: &lt;code&gt;hostPath&lt;/code&gt;, &lt;code&gt;hostNetwork&lt;/code&gt;, privileged containers, or other host-level access.&lt;/p&gt;

&lt;p&gt;Be explicit about that. Label those namespaces &lt;code&gt;privileged&lt;/code&gt; so the exception is documented in the cluster state rather than living as folklore.&lt;/p&gt;

&lt;p&gt;Istio adds another common surprise — relevant if you run &lt;a href="https://dev.to/blog/zero-trust-networking-kubernetes-istio-service-mesh"&gt;a mesh for zero-trust networking&lt;/a&gt;. With sidecar injection but without &lt;a href="https://istio.io/latest/docs/setup/additional-setup/cni/" rel="noopener noreferrer"&gt;Istio CNI&lt;/a&gt;, the injected &lt;code&gt;istio-init&lt;/code&gt; container needs &lt;code&gt;NET_ADMIN&lt;/code&gt; and &lt;code&gt;NET_RAW&lt;/code&gt; to set up traffic redirection. That is enough to fail &lt;strong&gt;baseline&lt;/strong&gt;, not just &lt;code&gt;restricted&lt;/code&gt;. The usual fix is to enable the CNI node agent and keep &lt;code&gt;istio-system&lt;/code&gt; itself &lt;code&gt;privileged&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why version pinning matters more than it looks
&lt;/h2&gt;

&lt;p&gt;A pinned PSA version is an operational control, not a documentation detail.&lt;/p&gt;

&lt;p&gt;Kubernetes &lt;code&gt;v1.34&lt;/code&gt; added a baseline control that blocks the &lt;code&gt;host&lt;/code&gt; field in &lt;code&gt;httpGet&lt;/code&gt; and &lt;code&gt;tcpSocket&lt;/code&gt; probes and lifecycle hooks. The reason is sensible: that field can be abused as an SSRF path through the kubelet. The operational consequence is that a cluster upgrade can start rejecting manifests that were fine the day before — &lt;em&gt;if&lt;/em&gt; your labels track &lt;code&gt;latest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With version pinning, you choose when that tighter rule lands. With &lt;code&gt;$pss&lt;/code&gt; still set to the minor version you were running before the upgrade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl label &lt;span class="nt"&gt;--overwrite&lt;/span&gt; ns payments &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/enforce-version&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pss&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/warn-version&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pss&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/audit-version&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pss&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes upgrades much less dramatic. Upgrade the cluster first. Move the policy version when you are ready to deal with the findings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RBAC hole nobody should ignore
&lt;/h2&gt;

&lt;p&gt;PSA policy lives on namespace labels. That means anyone who can update namespace labels can also weaken or remove enforcement.&lt;/p&gt;

&lt;p&gt;Review that RBAC carefully. It is easy to focus on pod-creation rights and forget that &lt;code&gt;update&lt;/code&gt; on namespaces is effectively policy-admin access.&lt;/p&gt;

&lt;p&gt;There is a more central exemption mechanism through the API server &lt;code&gt;AdmissionConfiguration&lt;/code&gt;, where you can exempt namespaces, usernames, or runtime classes. That is useful on self-managed control planes. On managed services such as EKS, GKE, and AKS, you usually do not get to edit that file at all, which means namespace labels are the real policy surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Pod Security Admission stops
&lt;/h2&gt;

&lt;p&gt;PSA is the built-in floor. That is its job, and it is a good one.&lt;/p&gt;

&lt;p&gt;There is no mutation, so no PSP-style defaulting is waiting to save a sloppy manifest. There is no custom rule language, and no way to express fine-grained per-workload exceptions inside one namespace. Once you want those things, you are into policy engines such as Kyverno or OPA Gatekeeper.&lt;/p&gt;

&lt;p&gt;That is not a weakness so much as a division of labour. Use PSA to guarantee the cluster-wide minimum. Use a policy engine above it when you need mutation, richer exceptions, or application-specific constraints.&lt;/p&gt;

&lt;p&gt;PodSecurityPolicy is gone. The sensible replacement is not to rebuild PSP in another tool and pretend nothing changed. It is to accept the smaller built-in model for what it is: predictable, cheap to operate, and strong enough to become the default floor across a cluster. That is a better trade than a perfect policy system nobody trusts enough to enable.&lt;/p&gt;

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