Kustomize vs Helm (and together)
When to use which, or both.
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.
$ 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
==> Linting ./myapp[INFO] Chart.yaml: icon is recommended1 chart(s) linted, 0 chart(s) failed---# Source: myapp/templates/deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata:name: web-myapplabels:helm.sh/chart: myapp-0.1.0app.kubernetes.io/name: myappapp.kubernetes.io/instance: webapp.kubernetes.io/version: "1.16.0"app.kubernetes.io/managed-by: Helmspec:replicas: 3 # {{ .Values.replicaCount }} filled from values-prod.yamlselector:matchLabels:app.kubernetes.io/name: myappapp.kubernetes.io/instance: webtemplate:...
$ helm upgrade --install web ./myapp -n prod -f values-prod.yaml$ helm history web -n prod
Release "web" has been upgraded. Happy Helming!NAME: webLAST DEPLOYED: Fri Jul 17 09:14:02 2026NAMESPACE: prodSTATUS: deployedREVISION: 2TEST SUITE: NoneREVISION UPDATED STATUS CHART APP VERSION DESCRIPTION1 Fri Jul 17 08:55:10 2026 superseded myapp-0.1.0 1.16.0 Install complete2 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.
$ kustomize build overlays/prod # deterministic YAML from base + patches; kubectl apply -k runs the same build, then applies it
apiVersion: apps/v1kind: Deploymentmetadata:labels:app: webvariant: prodname: prod-webspec:replicas: 3selector:matchLabels:app: webtemplate:metadata:labels:app: webvariant: prodspec:containers:- image: myco/web:1.9.2name: web...
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.
# build with: kustomize build --enable-helm .apiVersion: kustomize.config.k8s.io/v1beta1kind: KustomizationhelmCharts:- name: ingress-nginxrepo: https://kubernetes.github.io/ingress-nginxversion: 4.11.3 # PIN it; unpinned builds drift and are non-reproduciblereleaseName: ingressnamespace: ingress-nginxvaluesInline:controller:replicaCount: 2admissionWebhooks:enabled: false # skip the webhook Jobs so the render stays leanresources:- networkpolicy.yaml # a NEW object; ingress-nginx ships none, so it goes herepatches:- path: harden-controller.yaml # change a field INSIDE the rendered Deployment
$ kustomize build --enable-helm . | grep -E '^kind:'
kind: ServiceAccountkind: Rolekind: ClusterRolekind: RoleBindingkind: ClusterRoleBindingkind: ConfigMapkind: Servicekind: Deploymentkind: IngressClasskind: 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).
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).
#!/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
# sits next to the script; names what Helm handed us plus your editsresources:- all.yamlpatches:- path: readonly-rootfs.yaml # flip a securityContext field the chart hardcodes
$ helm upgrade --install myapp ./myapp -n prod \--post-renderer ./kustomize-post.sh
Release "myapp" has been upgraded. Happy Helming!NAME: myappLAST DEPLOYED: Fri Jul 17 10:22:15 2026NAMESPACE: prodSTATUS: deployedREVISION: 3TEST 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.
$ helm get manifest myapp -n prod | grep -A1 readOnlyRootFilesystem
readOnlyRootFilesystem: true # chart shipped it as false; the post-render patch flipped itrunAsNonRoot: true
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.
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.