Two ways teams tame Kubernetes YAML across environments: Helm's templated charts and Kustomize's overlays. When to reach for each, and how both fit into GitOps.
You write a clean Kubernetes Deployment. It works. Then someone says "we need it in staging too," so you copy the YAML into a new folder and change the image tag, the replica count, and the host in the Ingress. Then prod arrives, and you copy it again, three replicas this time, a different resource limit, a real TLS secret. Now you have three near-identical copies of the same manifest, and the only honest description of the difference between them lives in your head.
Three months later someone adds a new environment variable to the dev copy and forgets the other two. Prod drifts. A debugging session that should take ten minutes takes an afternoon because nobody can tell which version is the source of truth. This is the copy-paste tax, and every team that runs Kubernetes pays it until they pick a packaging tool.
The two dominant answers are Helm and Kustomize. They solve the same problem, one set of manifests, many environments, in opposite ways. This article shows how each works, gives you a small example of both, and helps you decide which one (or both) belongs in your stack.
Who this is for
Engineers who already write Kubernetes manifests by hand and feel the pain of maintaining one set of YAML across dev, staging, and prod. If `kubectl apply -f` and a Deployment are familiar, you're ready. New to running clusters? Start with [Kubernetes in Production: Beyond the Tutorial](/blog/kubernetes-in-production) first.
Templates vs patches
Helm parameterizes your manifests with a templating language; Kustomize layers plain-YAML patches on top of plain-YAML bases. Same destination, two philosophies.
The cleanest way to feel the difference is an analogy. Think about how you'd produce three versions of a printed letter.
A mail-merge letter with {{name}} blanks you fill from a spreadsheetA Helm chart with {{ .Values.x }} placeholders filled from values.yaml
A printed master letter with sticky-note corrections clipped on topA Kustomize base manifest with overlay patches applied per environment
The spreadsheet of names and addressesEach environment's values file (dev / staging / prod)
A bundle of letters mailed under one tracking numberA Helm release, a named, versioned, rollback-able install
Helm fills in blanks; Kustomize edits a finished document.
Helm treats your YAML as a template, the chart isn't valid Kubernetes YAML on its own, it's a program that *renders into* YAML. Kustomize treats your YAML as data, every file is a real, applyable manifest, and overlays just describe the diffs. That single distinction drives almost every trade-off below.
The picture
Both tools sit between your source manifests and the cluster. You author once, then a per-environment input produces per-environment output, which finally lands in a namespace. Here is the shape of it:
Base manifests flow through Helm values OR Kustomize overlays into per-env output, then to the cluster.
1
Author the base once
Write the Deployment, Service, and Ingress with sensible defaults. This is the single source of truth that every environment shares.
2
Describe what differs per environment
With Helm, that's a values file per env. With Kustomize, that's an overlay folder with a kustomization.yaml and small patches.
3
Render the final YAML
`helm template` or `kustomize build` turns base + env-input into concrete, fully-resolved manifests. No placeholders left.
4
Apply to the matching namespace
The rendered output goes to the cluster, either directly via the CLI, or (better) committed to Git and reconciled by a GitOps controller.
Helm vs Kustomize: a head-to-head
The two tools overlap in purpose but diverge in mechanics. The table is the fast way to internalize the trade-offs before we look at code.
Dimension
Helm
Kustomize
Templating
Yes, Go templates with {{ }}, conditionals, loops, functions
No, plain YAML only, diffs via strategic-merge & JSON patches
Releases
First-class: named, versioned installs you can roll back with one command
None, it just emits YAML; lifecycle is whatever applies it
Packaging & sharing
Charts are versioned, published to registries, installed by anyone
No package format; you copy/fork or reference a remote base by URL
Learning curve
Steeper, a template language, values precedence, chart structure
Gentle, if you know YAML and diffs, you mostly already know it
Built into kubectl (`kubectl apply -k`) plus the standalone CLI
When to use
Distributing reusable apps; lots of env knobs; need rollback
Your own apps; small, readable per-env diffs; want zero magic
Same goal, different machinery.
The same app, both ways
Imagine a small web app with a Deployment. Below is the Helm side, a values snippet that prod would supply to override the chart defaults. Notice the values file is *not* Kubernetes YAML; it's the data that fills the chart's {{ .Values.* }} placeholders.
values-prod.yaml (Helm)
yaml
# Overrides the chart defaults for production.# The chart's templates read these via {{ .Values.* }}.replicaCount: 3image:
repository: registry.example.com/web
tag: "1.8.0"resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"memory: 512Mi
ingress:
enabled: truehost: app.example.com
tls: true# Install with:# helm upgrade --install web ./web-chart -f values-prod.yaml
Now the Kustomize side. The base is real, applyable YAML; the prod overlay is a tiny kustomization.yaml plus a patch that changes only what differs. No placeholders anywhere, every file would kubectl apply on its own.
overlays/prod/kustomization.yaml (Kustomize)
yaml
# overlays/prod/kustomization.yamlapiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod
resources:
- ../../base # the shared, real manifestsimages:
- name: registry.example.com/web
newTag: "1.8.0"# bump the tag without editing the basereplicas:
- name: web
count: 3patches:
- patch: |-
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: 512Mi
target:
kind: Deployment
name: web
# Render with:# kubectl kustomize overlays/prod (or: kustomize build overlays/prod)
Try it in the browser
Practice both flows hands-on in the [Helm lab](/labs/helm) and the [kubectl lab](/labs/kubectl), render, diff, and apply without touching a real cluster.
How they fit GitOps
Neither tool is a deployment system, they both just *produce YAML*. That makes them a perfect fit for GitOps, where Git is the source of truth and a controller continuously reconciles the cluster to match. You commit the chart-plus-values or the base-plus-overlays, and the controller renders and applies them for you.
Both ArgoCD and Flux speak Helm and Kustomize natively. Point Argo at a chart and a values file, or at an overlay folder, and it runs helm template / kustomize build under the hood, diffs the result against live state, and self-heals drift. The win: humans never run helm install or kubectl apply against prod, they open a pull request, and the merge *is* the deploy.
Render in the pipeline, not the cluster, let the GitOps controller own helm template / kustomize build so what's in Git is exactly what runs.
One repo, many overlays, keep base/ plus overlays/dev|stage|prod (or one chart with per-env values) so a diff between environments is a real, reviewable diff.
Pin versions in Git, image tags and chart versions live in committed files, so every change has an author, a timestamp, and a one-click revert.
Common mistakes that cost hours
Over-templating Helm charts. Exposing forty knobs in values.yaml for an app three teams use makes the chart unreadable and every upgrade scary. Template only what genuinely varies; hardcode the rest.
Putting logic in templates. Nested {{ if }}/{{ range }} blocks turn a manifest into a program nobody can debug. When you find yourself writing conditionals around conditionals, the answer is usually a second values file, or Kustomize.
Letting environments drift. Hand-editing live resources with kubectl edit, or applying an overlay manually "just this once," silently desyncs the cluster from Git. Render from source every time and let GitOps catch drift.
Forgetting Kustomize has no rollback. It emits YAML and walks away. If you need versioned releases and helm rollback, that lifecycle is on you (or on your GitOps controller via Git history), don't assume the tool provides it.
Mixing both without a reason. Helm-inside-Kustomize and Kustomize-post-rendering-Helm are valid but advanced. Pick one as your default and only reach for the combo when a concrete need forces it.
Takeaways
The whole article in six lines
Maintaining one set of manifests across environments by copy-paste guarantees drift, pick a packaging tool.
Helm = templates: charts with {{ }} placeholders filled by per-env values, plus versioned, rollback-able releases.
Kustomize = patches: a real-YAML base with small per-env overlays, no templating, built into kubectl.
Choose Helm to distribute reusable apps with many knobs and rollback; choose Kustomize for your own apps with small, readable diffs.
Both just render YAML, which makes them a natural fit for GitOps with ArgoCD or Flux.
Avoid over-templating, logic in templates, and silent drift, render from source every time.
Where to go next
Packaging is one piece of running Kubernetes well. Pair it with the operational and delivery practices around it:
Practice in the Helm lab and the kubectl lab, author a chart, build an overlay, diff the output.
Follow the full DevOps Engineer path to put packaging, pipelines, and observability together.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.