· Charlie Holland · DevOps · 13 min read
Helm vs Kustomize: Pick One and Stop Arguing
I've deployed Kubernetes manifests with both Helm and Kustomize across half a dozen enterprise clients. The debate is tiresome. They solve different problems. Here's when each one actually makes sense.
I need to get something off my chest. In the past eighteen months, I’ve sat through at least four “Helm vs Kustomize” debates at different clients. Each one lasted an hour minimum. Each one produced more heat than light. At a Big Four consultancy, the argument consumed an entire architecture review session. At a major media conglomerate, two senior engineers had a public disagreement about it in a Slack channel that went on for three days.
None of these debates needed to happen. Helm and Kustomize solve different problems. The “vs” framing is wrong. But nuance doesn’t generate conference talks, so here we are.
I’ve used Helm extensively at four clients — a Big Four consultancy, a media conglomerate, a global industrial manufacturer, and a banking institution. I’ve used Kustomize at three others. I’ve seen teams try to use both on the same deployment, which is the Kubernetes equivalent of wearing two watches. I have opinions. Let me share them.
Helm: the package manager
Helm is a package manager for Kubernetes. Full stop. That’s its job, and it does that job well. You take a set of Kubernetes manifests, templatise them with Go templates, package them into a chart, and distribute them via a chart repository. Consumers install the chart, pass in values, and get a configured deployment.
Helm 3 (released November 2019) removed Tiller — the server-side component that was a security nightmare and the primary source of Helm 2 complaints. With Tiller gone, Helm is a client-side tool that templates manifests and applies them via the Kubernetes API. It’s been a year, and the improvement is real. If your opposition to Helm was “Tiller is a security risk” — and it was a valid objection — that argument is dead.
Where Helm shines
Third-party software. This is Helm’s killer use case and the one that ends most debates. When you need to deploy Prometheus, Nginx Ingress Controller, cert-manager, Elasticsearch, PostgreSQL, or any of the hundred other infrastructure components that a real Kubernetes cluster needs, Helm charts are the distribution mechanism. The Bitnami charts, the prometheus-community charts, the ingress-nginx chart — these are well-maintained, battle-tested, and configurable via values files.
Try installing Prometheus with raw manifests. The kube-prometheus-stack chart manages over a thousand lines of YAML across dozens of resources, with sensible defaults and extensive configurability. You’re not going to maintain that yourself. You’re going to helm install it and move on with your life.
Dependency management. Helm charts can declare dependencies on other charts. Your application chart can depend on a Redis chart and a PostgreSQL chart. helm dependency update pulls them in. This is genuine dependency management, not copy-paste, and it’s something Kustomize simply doesn’t do.
Release management. Helm tracks releases — it knows what version of what chart is deployed, when it was installed, and what values were used. helm list, helm history, helm rollback — these are useful operational commands. Not earth-shattering, but genuinely helpful when something goes wrong at 3am and you need to know what changed.
Where Helm hurts
Go templates. Lord, the Go templates.
Here’s what a conditional environment variable looks like in a Helm template:
env:
- name: DATABASE_URL
value: {{ .Values.database.url | quote }}
{{- if .Values.database.ssl.enabled }}
- name: DATABASE_SSL_MODE
value: {{ .Values.database.ssl.mode | default "require" | quote }}
{{- end }}
{{- range .Values.extraEnvVars }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}That {{- with the dash is whitespace control. Get it wrong and your YAML has leading whitespace that breaks parsing. The quote function is necessary because YAML interprets unquoted values, and Go templates don’t quote by default. The range is a loop. The .Values is a reference to the values hierarchy.
This is not readable. It’s a templating language designed for HTML (Go’s text/template package), shoehorned into YAML, producing more YAML. Every non-trivial Helm chart I’ve worked with has at least one template bug caused by whitespace, quoting, or indentation. The debugging experience is helm template . | less and squinting at the output.
For comparison, here’s the same thing in actual YAML without templates:
env:
- name: DATABASE_URL
value: "postgres://db.example.com:5432/myapp"
- name: DATABASE_SSL_MODE
value: "require"Readable. Obvious. No template functions, no whitespace traps, no quote helpers. This is what Kustomize works with.
Chart complexity creep. Helm charts start simple and grow into monsters. The Bitnami PostgreSQL chart has a values.yaml that’s over 1,000 lines long. That’s one thousand lines of configuration options for PostgreSQL. Most users need about twenty of them. But because Helm charts are designed to be general-purpose — one chart for every deployment scenario — they accumulate configuration surface area like a barnacle collects on a hull.
I’ve seen internal application charts at clients with 200+ line values files. The chart was templated to handle every possible environment variation — different ingress controllers, different storage classes, different resource limits, optional sidecars, conditional CronJobs. The values.yaml had become a DSL for describing the application. At that point you haven’t simplified Kubernetes deployment — you’ve replaced Kubernetes YAML with your own YAML that requires understanding both Kubernetes and Helm.
Helm test hooks that nobody uses. Helm has a test framework — you can define test pods that run after installation to verify the deployment. In three years of Helm usage across multiple clients, I have never seen a team use Helm tests in production. Not once. The feature exists. It’s documented. Nobody uses it. Teams test deployments through their CI/CD pipelines, health checks, and smoke tests — not through Helm’s built-in mechanism.
Kustomize: the overlay engine
Kustomize takes a fundamentally different approach. Instead of templating, you write plain Kubernetes YAML — no placeholders, no Go templates, no values files. Then you use overlays to modify that base YAML for different environments. Need to change the replica count for production? Write a patch. Need different resource limits for staging? Write a patch. Need an extra environment variable for development? Write a patch.
Kustomize has been built into kubectl since Kubernetes 1.14 (March 2019), so you don’t even need to install it separately. kubectl apply -k ./overlays/production applies your base manifests with the production patches. No Helm, no Tiller (even though Tiller is gone, the point stands — no extra tooling).
Where Kustomize shines
Plain YAML. Your base manifests are valid Kubernetes YAML. You can kubectl apply -f them directly. You can read them without understanding a templating language. New team members can look at the base deployment and understand what’s being deployed without learning Helm’s template syntax.
This sounds trivial. It’s not. At a banking institution, I watched a junior engineer spend two hours trying to understand a Helm chart that would have taken ten minutes to understand as raw YAML. The abstraction was adding cognitive overhead, not removing it.
Environment overlays. Kustomize’s overlay model is elegant for the most common Kubernetes customisation scenario: the same application deployed to different environments with different configurations.
Here’s a typical directory structure:
app/
base/
kustomization.yaml
deployment.yaml
service.yaml
configmap.yaml
overlays/
dev/
kustomization.yaml
replica-count.yaml
staging/
kustomization.yaml
resource-limits.yaml
production/
kustomization.yaml
replica-count.yaml
resource-limits.yaml
ingress-patch.yamlThe base has your complete, valid deployment. Each overlay adds or modifies what’s different. The production overlay might look like:
# overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patchesStrategicMerge:
- replica-count.yaml
- resource-limits.yaml
- ingress-patch.yaml
namePrefix: prod-
commonLabels:
environment: productionAnd a patch:
# overlays/production/replica-count.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 5That’s it. No template syntax. No values hierarchy. Just “here’s what’s different in production.” The merge is strategic — Kustomize understands Kubernetes resource structures and merges intelligently, not just at the YAML key level.
Where Kustomize struggles
Strategic merge patches: powerful but confusing. Kustomize supports strategic merge patches and JSON patches. Strategic merge patches “know” about Kubernetes resources and merge lists intelligently — for example, adding a container to a pod spec without replacing all existing containers. JSON patches use RFC 6902 operations (add, remove, replace) for precise modifications.
The problem is that the merge behaviour isn’t always obvious. If you patch a container’s env vars, does it merge the env list or replace it? (It merges, using the name field as a key — but only because the Kubernetes strategic merge patch metadata specifies name as a merge key for env vars.) If you patch a volume mount list in a custom resource that Kustomize doesn’t know about? It replaces. Understanding which lists merge and which replace requires understanding Kubernetes’ strategic merge patch metadata, which is not beginner-friendly.
I’ve had teams create patches that silently replaced lists when they intended to merge, and the failure wasn’t obvious until the deployment was missing half its environment variables. Debugging means running kustomize build and diffing the output against what you expected — similar to Helm’s helm template, but at least the input files are readable.
No dependency management. Kustomize has no concept of external dependencies. If your application needs Redis, Kustomize can’t pull a Redis chart. You either maintain your own Redis YAML in the repository, or you use a separate tool to deploy Redis. For infrastructure components, this is a real gap.
No release tracking. Kustomize doesn’t track what’s deployed. It generates manifests; kubectl applies them. If you want to know what version of your application is running in production, you need to look at the Git commit, not ask Kustomize. For teams with mature GitOps workflows, this is fine — Git is the source of truth. For teams without that discipline, it’s a gap.
Limited configurability for shared manifests. If you want to publish a reusable Kubernetes deployment that other teams can configure — like an open-source operator or a shared platform component — Kustomize is a poor fit. The consumers would need to write patches against your base manifests, and any change to the base structure could break their patches. Helm’s values-based configuration model handles this much better.
The teams that use both
Inevitably, someone suggests using Helm and Kustomize together. “Use Helm for third-party charts, Kustomize for our applications, and for the cases where we need to customise a Helm chart beyond what values allow, we can use Kustomize to patch the Helm output.”
This is the helm template | kubectl apply approach, or its slightly more sophisticated cousin, the Kustomize Helm chart inflator. You render the Helm chart to YAML, then feed it through Kustomize for additional patching.
I’ve seen this at two clients. Both times it was a mess.
The problem is layered abstractions. When something goes wrong with the deployment, you now have three layers to debug: the Helm values, the Helm template output, and the Kustomize patches on top. Is the issue in the values? In the chart template? In the Kustomize patch? You’re debugging a pipeline with multiple transformation stages, and the output of each stage is ephemeral.
The other problem is tooling. Your CI/CD pipeline now needs both Helm and Kustomize. Your team needs to understand both. Your code review process needs reviewers who can reason about both. This is overhead that doesn’t pay for itself.
At the banking institution, we eventually standardised on one approach per category: Helm for all third-party deployments (ingress controllers, monitoring, databases), Kustomize for all internal applications. The two approaches never touched the same deployment. This worked. The moment they overlapped — someone wanted to add a sidecar to the ingress controller that wasn’t supported by the chart’s values — we made the change upstream in the chart values, not in a Kustomize patch layer.
The chart museum situation
Quick aside on chart distribution, because it’s relevant. Helm charts need to live somewhere — a chart repository. The original approach was ChartMuseum, a dedicated chart repository server. It works. It’s also yet another service to run and maintain.
Helm 3 added experimental support for OCI registries — storing charts in container registries alongside your Docker images. It’s a good idea. Container registries are infrastructure you already have. But as of late 2020, OCI support is still experimental. Some registries support it well (ACR, ECR), others have partial support or don’t support it at all.
For most teams, the pragmatic answer is: host charts in a Git repository and use helm repo add with a raw URL, or use your cloud provider’s container registry with OCI support if available. Don’t deploy ChartMuseum unless you have dozens of internal charts to manage.
My recommendation
After eighteen months of deploying with both tools across half a dozen enterprise clients, here’s what I’ve landed on:
Use Helm for software distribution. When you’re deploying software that someone else built — Prometheus, Nginx, PostgreSQL, Elasticsearch, ArgoCD — use their Helm chart. The chart maintainers have encoded operational knowledge into the templates and values. You benefit from their experience. Configure it via values.yaml and move on.
Use Kustomize for your own applications. When you’re deploying your own services — the applications your team builds and operates — use Kustomize. Your base manifests are readable YAML. Your environment differences are explicit in the overlays. New team members can understand what’s deployed by reading the files, not by reverse-engineering a template.
Don’t use both on the same deployment. If a Helm chart doesn’t support a configuration option you need, either contribute the option upstream, fork the chart, or accept the limitation. Don’t layer Kustomize on top of Helm output. The complexity isn’t worth it.
If you can only pick one: Helm. Because you’re going to need third-party charts regardless, and having one tool is better than having two. Kustomize is more elegant for your own applications, but Helm can do the job too, even if the templates make your eyes bleed.
The real advice
The Helm vs Kustomize debate is a symptom of a deeper issue: Kubernetes manifest management is an unsolved problem. The manifests are verbose, environment-specific configuration is fiddly, and the ecosystem hasn’t converged on a single approach because no single approach handles every scenario well.
The actual problems I see at clients aren’t about Helm vs Kustomize. They’re about:
No GitOps workflow. Manifests that are applied manually from laptops, not from a Git repository through a pipeline. Fix this first, and the Helm/Kustomize question becomes less important.
Too many environments. Dev, staging, QA, UAT, pre-prod, prod. Six environments with six different configurations, maintained by hand, drifting constantly. Reduce the number of environments, or accept the cost of maintaining them.
No ownership. The platform team writes the Helm charts, the application teams consume them, and nobody owns the gap between “the chart supports X” and “we need Y.” This is an organisational problem, not a tooling problem.
Pick a tool. Standardise. Write down the decision. Move on to the problems that actually matter. The argument about whether Go templates or strategic merge patches are the right abstraction for Kubernetes YAML is — and I say this with the full weight of twenty-five years of professional experience — not worth your time.
Stop arguing. Ship something.
