CoursesKustomizeKustomize vs Helm (and together)

Kustomize vs Helm (and together)

When to use which, or both.

Advanced12 min · lesson 11 of 12

Helm and Kustomize hand the cluster the same thing at the end: a stream of plain Kubernetes YAML (a plain-text format for describing configuration) that the API server (the control-plane component that accepts and stores your resource definitions) will accept. They walk there from opposite directions. Helm is a mail merge. The chart author writes a form letter with blanks in it, things like {{ .Values.replicaCount }}, and you fill those blanks from a values file before the letter goes out. Kustomize is a stack of clear adjustment layers laid over a finished photo. The base underneath is already valid, complete YAML, and each overlay tweaks it without touching the original. Neither one is the more advanced tool. They answer different questions, and the genuinely useful move is knowing you can run both at once.

The choice almost never comes down to a feature checklist. It comes down to who owns the YAML. If you are packaging software for strangers to install, with a hundred settings you cannot predict in advance, you need parameters, conditionals, and loops. That is Helm's template engine, and Kustomize deliberately ships none of it (see ku-what). If you own the manifests and only need dev, staging, and production to differ in a few dozen fields, overlays keep the source readable and reviewable, because every layer is real YAML you can read straight, with no rendering step between you and understanding what it does (see ku-overlays, ku-patches).

Templating Versus Overlays

Helm gives you one thing Kustomize does not: a named, versioned release with a history you can roll back. When you run helm upgrade --install, Helm records that revision inside the cluster (it stores it in a Secret in the release namespace), so helm history lists every deploy and helm rollback walks you back to a known-good one. Kustomize keeps no runtime state at all. Its build output is a pure function of the files in Git: same inputs, same YAML, every single time, with nothing stored in the cluster to drift out from under you. Reach for Helm when distribution, packaging, and release history are the point. Reach for Kustomize when the manifests are yours and you want the deploy to be nothing beyond the files you can see in the repo.

terminal
$ helm create myapp # scaffolds Chart.yaml, values.yaml, templates/
$ helm lint ./myapp
$ helm template web ./myapp -f values-prod.yaml # render to YAML, no cluster touched
output
==> Linting ./myapp
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed
---
# Source: myapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-myapp
labels:
helm.sh/chart: myapp-0.1.0
app.kubernetes.io/name: myapp
app.kubernetes.io/instance: web
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
spec:
replicas: 3 # {{ .Values.replicaCount }} filled from values-prod.yaml
selector:
matchLabels:
app.kubernetes.io/name: myapp
app.kubernetes.io/instance: web
template:
...
terminal
$ helm upgrade --install web ./myapp -n prod -f values-prod.yaml
$ helm history web -n prod
output
Release "web" has been upgraded. Happy Helming!
NAME: web
LAST DEPLOYED: Fri Jul 17 09:14:02 2026
NAMESPACE: prod
STATUS: deployed
REVISION: 2
TEST SUITE: None
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 Fri Jul 17 08:55:10 2026 superseded myapp-0.1.0 1.16.0 Install complete
2 Fri Jul 17 09:14:02 2026 deployed myapp-0.1.0 1.16.0 Upgrade complete

That revision list is Helm-only. helm rollback web 1 -n prod would put revision 1 back on the cluster in one command, because Helm remembers. Kustomize remembers nothing. You point it at an overlay and it prints the finished YAML, deterministically, with no server-side record of what came before. The output below is not a template with holes in it; it is complete, valid YAML that a reviewer can read as-is.

terminal
$ kustomize build overlays/prod # deterministic YAML from base + patches; kubectl apply -k runs the same build, then applies it
output
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: web
variant: prod
name: prod-web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
variant: prod
spec:
containers:
- image: myco/web:1.9.2
name: web
...
Which Tool For The Job
Who writes the manifests, and does the app ship to other people?
You own them; envs differ in a few fields
Kustomize overlays
plain YAML, no engine, build is a pure function of Git
You package it for strangers to install
Helm chart
parameters, conditionals, versioned releases, rollback
A third-party chart you must adjust
Both
inflate with helmCharts, or patch with a post-renderer

Running Both With helmCharts

The most common real setup is a third-party chart plus your own opinions. You do not want to fork ingress-nginx, but you do need to relabel it, add a NetworkPolicy (a Kubernetes firewall rule that decides which pods may talk to a workload), or patch a field the chart never exposed (patch meaning change specific fields in a resource without rewriting the whole file). Kustomize handles this by hiring Helm as a subcontractor. Its helmCharts field shells out to helm template to render the chart, then treats that rendered output as a base you can patch, relabel, and transform like any other (see ku-transformers). Pin the version, or your build is not reproducible.

kustomization.yaml
# build with: kustomize build --enable-helm .
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
helmCharts:
- name: ingress-nginx
repo: https://kubernetes.github.io/ingress-nginx
version: 4.11.3 # PIN it; unpinned builds drift and are non-reproducible
releaseName: ingress
namespace: ingress-nginx
valuesInline:
controller:
replicaCount: 2
admissionWebhooks:
enabled: false # skip the webhook Jobs so the render stays lean
resources:
- networkpolicy.yaml # a NEW object; ingress-nginx ships none, so it goes here
patches:
- path: harden-controller.yaml # change a field INSIDE the rendered Deployment
terminal
$ kustomize build --enable-helm . | grep -E '^kind:'
output
kind: ServiceAccount
kind: Role
kind: ClusterRole
kind: RoleBinding
kind: ClusterRoleBinding
kind: ConfigMap
kind: Service
kind: Deployment
kind: IngressClass
kind: NetworkPolicy # <- yours; ingress-nginx does not ship one

Look at that last line. That NetworkPolicy is a new object you listed under resources, sitting on top of the upstream chart with no fork in sight. The harden patch reached into the rendered Deployment and changed a field the chart's values never exposed, and replicaCount: 2 flowed through your valuesInline into that same Deployment. This is powerful, and it is a supply-chain surface you have to guard (supply chain here means every outside piece of software, and every repo, your build reaches out and trusts). An attacker who controls that chart repo, or a version you left unpinned, gets to add resources to your cluster the moment you build. The defender's job is boring and effective: pin the chart to an exact version, verify its provenance (the signed record, in the chart's .prov file, of who built it and from what) with helm verify, and scan the rendered output before anything reaches the cluster (see ku-remote, ku-gitops).

--enable-helm runs a real helm binary, not a library
The helmCharts field does not parse the chart itself. It execs the helm binary on your PATH (the list of directories your shell searches for programs) and runs helm template, which runs the chart's own Go template code and the Sprig functions it calls (Sprig is the template function library those charts lean on), then pulls the chart from a remote repo. That is why the feature stays off unless you pass --enable-helm, or set kustomize.buildOptions: --enable-helm in Argo CD's argocd-cm ConfigMap (a Kubernetes object that holds configuration key-values). Argo CD and Flux are the two common GitOps controllers, agents that watch a Git repo and continuously make the cluster match it. Three traps follow. One: your CI (continuous integration) runners and your GitOps controller both need helm installed, or the build dies with a cryptic 'helm not found'. Two: an unpinned version fetches whatever is latest that day, so your supposedly declarative output changes silently between runs. Three: Flux's kustomize-controller does not run the inflator at all (the inflator being the built-in step that expands a Helm chart into YAML), so this path only works under Argo CD or standalone Kustomize.

Helm-Owned Lifecycle With A Post-Renderer

Sometimes you want Helm to stay in charge. You want helm history, helm rollback, and a named release, but the chart flatly will not let you change one field it hardcodes. A post-renderer bridges that gap without a fork. Think of it as a final inspector at the end of Helm's assembly line: Helm renders the chart, hands the whole manifest stream to your program on standard input, and installs whatever your program prints back. A tiny wrapper that pipes that stream into kustomize build lets you patch any field the chart's values could never reach, down to a securityContext setting (the per-container security options, like whether the container's root filesystem is read-only).

kustomize-post.sh
#!/bin/sh
# kustomize-post.sh (chmod +x it; the #! line, the shebang, MUST be line 1)
# Helm streams the rendered chart on stdin. Capture it, let Kustomize
# re-read and patch it, then print the result for Helm to install.
cat > all.yaml && kustomize build . && rm all.yaml
kustomization.yaml
# sits next to the script; names what Helm handed us plus your edits
resources:
- all.yaml
patches:
- path: readonly-rootfs.yaml # flip a securityContext field the chart hardcodes
terminal
$ helm upgrade --install myapp ./myapp -n prod \
--post-renderer ./kustomize-post.sh
output
Release "myapp" has been upgraded. Happy Helming!
NAME: myapp
LAST DEPLOYED: Fri Jul 17 10:22:15 2026
NAMESPACE: prod
STATUS: deployed
REVISION: 3
TEST SUITE: None

Now verify the edit actually landed, because a post-renderer that silently no-ops is worse than no post-renderer. Ask the cluster what Helm stored, not what you hoped it stored. helm get manifest prints the exact YAML Helm applied for this release, so grepping it proves the patched field is really there.

terminal
$ helm get manifest myapp -n prod | grep -A1 readOnlyRootFilesystem
output
readOnlyRootFilesystem: true # chart shipped it as false; the post-render patch flipped it
runAsNonRoot: true
A post-renderer is an arbitrary program Helm will run
--post-renderer points Helm at an executable and installs whatever that program writes to stdout. Anyone who can edit the script, or drop a same-named program earlier on your PATH, controls what lands in the cluster. Treat the post-render script as deployment code: keep it in the repo, review every change to it, and never point --post-renderer at a file that a wider group can write than your manifests themselves. The upside is that the upstream chart stays cleanly upgradeable while your edits live in a small overlay right beside it.

The two patterns split cleanly along which tool owns the lifecycle. When Kustomize is your top-level tool, which is the usual Argo CD case, inflate the chart with helmCharts and patch on top. When Helm owns the release, so you keep its history and rollback, reach for a post-renderer and let Kustomize do surgical edits at the end. On Flux there is no --enable-helm switch to find, because its kustomize-controller does not run the inflator; you wire a third-party chart through a Flux HelmRelease instead and get the same result by a different road.

Quick check
01Your Argo CD app inflates ingress-nginx from a pinned helmCharts block and builds fine. The same repo moves to Flux, and the build now fails there. What gets it working again?
Incorrect — Unpinning changes nothing about which code paths the controller has, and it trades a reproducible build for whatever the chart repo happens to serve that morning.
Incorrect — Argo CD really does read kustomize.buildOptions from argocd-cm, which is why this feels right. Flux has no equivalent knob, because its kustomize-controller never implements the inflator step at all.
Correct — The inflator is an Argo CD and standalone-kustomize path. On Flux, a third-party chart travels through a HelmRelease, and your own manifests stay in the Kustomization beside it.
Incorrect — A missing helm binary is a real trap on CI runners and on Argo CD, but here the controller has no step that would shell out to it, so shipping the binary fixes nothing.
02A bad value reaches prod on Friday night. One workload was shipped with helm upgrade --install, the other with kustomize build on an overlay. What is the honest difference in how you undo each?
Correct — Helm writes each revision into a Secret in the release namespace, so helm history web -n prod lists them and helm rollback web 1 -n prod brings one back. Kustomize keeps no such record, so the repo is your only history.
Incorrect — Kustomize stores nothing server-side, so there is no revision to name. That is the deliberate trade for a build whose output depends only on the files you can read.
Incorrect — Reverting Git works for both, but Helm remembers on top of that, so a rollback can put prod right before your revert has even been reviewed.
Incorrect — Determinism means the same inputs give you the same YAML. It says nothing about restoring what was running an hour ago, which is the part Kustomize cannot do for you.
03A chart hardcodes readOnlyRootFilesystem: false and never exposes it as a value, and your team needs helm history and helm rollback to keep working for this release. Which approach fits?
Incorrect — The fork gets you the field, but you now own every upstream upgrade forever. Avoiding exactly that maintenance is the reason the post-renderer exists.
Correct — Helm still owns the release, so REVISION keeps climbing and rollback still works, while Kustomize edits the manifest stream on its way out. Prove it landed with helm get manifest myapp -n prod | grep -A1 readOnlyRootFilesystem.
Incorrect — That flips who is in charge. Kustomize becomes the top-level tool, Helm records no release, and the history and rollback you were told to keep disappear.
Incorrect — valuesInline only feeds values the chart's templates actually read. A field the chart hardcodes has no value wired behind it, so nothing you set can reach it.

Whichever road renders your YAML, the cluster only ever sees the final manifests, so that is exactly what your security checks must read. Run kustomize build or helm template, then send the output through a policy scanner (Checkov or Trivy, which flag insecure settings like a container running as root) and a schema validator (kubeconform, which confirms the YAML is structurally valid Kubernetes) before anything is applied. A template that looks safe and a patch that looks harmless can still combine into a Deployment that runs as root or quietly loses the NetworkPolicy you were counting on. The rendered result is the thing that runs. Review that, not the inputs that produced it.

Try this

Run helm create myapp # scaffolds Chart.yaml, values.yaml, templates/ on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: --enable-helm runs a real helm binary, not a library. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related