Helm is not your package manager: why I replaced it with Kustomize and plain manifests
Helm solved a real problem in 2016. In 2026 it creates more problems than it solves for most teams. After four years of maintaining Helm charts for startups across Europe, I ripped it out and replaced it with Kustomize overlays and plain Kubernetes manifests. Here is why, what changed, and the specific failure modes that made me stop trusting Helm as the default.
I have uninstalled Helm from every startup cluster I have worked on in the last eighteen months. Not because Helm is bad. Helm solved a real problem in 2016 when Kubernetes manifests were copy-pasted YAML files with no templating. But the problem Helm solved is not the problem most teams have in 2026, and the problems Helm creates are ones most teams do not realize they have until they are debugging a production incident at 2am caused by a chart template that rendered something nobody expected.
The decision is not ideological. I used Helm for years. I wrote charts, maintained them, published them. I taught teams to use Helm. Then I watched the same failure modes repeat across six different startups in Berlin, Amsterdam, Tallinn, and Dubai. The pattern is consistent enough that I now start every engagement by asking one question: what does your Helm chart actually render? Nobody has answered correctly on the first try.
The problem Helm solved, and why it is not your problem
Helm was built to solve package management for Kubernetes. The analogy is apt: Helm is apt or yum for Kubernetes. You install a chart, it renders templates, you get a running application. This makes sense for third-party applications. If you want to install Prometheus, or Grafana, or cert-manager, you want a packaged, versioned, configurable chart that someone else maintains. You do not want to write 400 lines of YAML for a monitoring stack you did not design.
The problem is that teams started using Helm for their own applications. They wrote charts for their own services. They templated their own deployment YAML, service YAML, ingress YAML, and config maps. And when you template your own manifests, you are not doing package management. You are doing configuration management with a templating language that was designed for a different purpose.
Here is what a typical Helm chart for a startup service looks like. I have seen this exact structure at four different companies.
my-service/
Chart.yaml
values.yaml
values-dev.yaml
values-staging.yaml
values-prod.yaml
templates/
deployment.yaml
service.yaml
ingress.yaml
configmap.yaml
_helpers.tpl
The deployment.yaml template looks something like this:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-service.fullname" . }}
labels:
{{- include "my-service.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicas | default 1 }}
selector:
matchLabels:
{{- include "my-service.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "my-service.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
resources:
{{- toYaml .Values.resources | nindent 12 }}
env:
{{- range $key, $val := .Values.env }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
This is not configuration. This is a program. It is a program written in a templating language that is Turing-incomplete but behaves like it is not. The {{- include "my-service.labels" . | nindent 4 }} is a function call with side effects on whitespace. The range loop over .Values.env is iteration with implicit context passing. The toYaml function serializes a Go struct to YAML, and the output depends on the shape of the input in ways that are not type-checked until render time.
You cannot read this file and know what it produces. You have to run helm template and inspect the output. You have to know the values files. You have to know the helper templates. You have to know the Helm version, because the templating functions change between Helm 2 and Helm 3, and some teams are still on Helm 2 charts despite Helm 2 being deprecated in 2019.
The four failure modes
I am going to describe the four failure modes I have seen repeatedly. These are not theoretical. Each one caused a real production incident.
Failure one: the whitespace bug
A team in Amsterdam had a Helm chart for their payment service. The chart had been working for months. An engineer added a new environment variable to values-prod.yaml:
env:
STRIPE_SECRET_KEY: "sk_live_..."
NEW_FEATURE_FLAG: "true"
The template rendered the env block with a range loop. The engineer ran helm template locally, checked the output, and it looked correct. He deployed. The payment service started crash-looping.
The issue was a whitespace bug in the template. The range loop had a {{- trim marker that, combined with the new key, produced YAML that was syntactically valid but semantically wrong. The rendered output had the env vars at the wrong indentation level. Kubernetes accepted the YAML but the container saw the variables as empty strings. The payment service could not connect to Stripe without the secret key, so it crashed.
The engineer spent forty minutes finding a whitespace bug in a templating language. This is not an engineering problem. It is a tooling problem. The templating language made it possible to introduce a bug that was invisible in the source, visible only in the rendered output, and difficult to trace because the rendered output was not what was stored in Git.
With plain manifests, this bug is impossible. You write YAML. The YAML is what you write. There is no rendering step. If the indentation is wrong, the YAML parser catches it before it gets to Kubernetes. You do not need to run a template command to see what your configuration actually is.
Failure two: the values file explosion
A team in Berlin had been using Helm for two years. Their chart had grown to support four environments: dev, staging, pre-prod, and prod. Each environment had its own values file. The values files had grown to 200 lines each. The base values.yaml was 300 lines. The total configuration surface was 1100 lines of YAML across five files.
The problem was not the size. The problem was the precedence. Helm merges values files in a specific order: base values, then environment-specific values, then command-line overrides. The merge is a deep merge, which means nested keys are merged, not replaced. If values.yaml has:
resources:
limits:
cpu: 500m
memory: 512Mi
And values-prod.yaml has:
resources:
limits:
memory: 1Gi
The result is:
resources:
limits:
cpu: 500m
memory: 1Gi
This is what most people expect. But if values-prod.yaml has:
resources:
requests:
cpu: 250m
The result is:
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
The requests key is merged in alongside limits. Now imagine this across 200 keys with four levels of nesting. Nobody can predict the final rendered output without running helm template. The configuration is not readable. It is computable. You have to execute the merge to know what you have.
I asked the Berlin team what their production resource limits were. Three engineers gave three different answers. They were all wrong. The actual limits were defined by a combination of values.yaml, values-prod.yaml, and a command-line override in their CI pipeline that nobody had documented. The source of truth was the rendered output, which was not stored anywhere. The Git repository was the source of truth for the templates, but the actual configuration lived in the merge result, which was ephemeral.
Failure three: the chart dependency hell
A team in Tallinn used Helm for their entire platform. They had eight services, each with its own chart. Three of those charts depended on a shared library chart for common templates. The library chart was versioned separately. When the library chart was updated, every dependent chart had to be updated, tested, and re-deployed.
The team had a dependency tree that looked like this:
platform/
api-service (chart, depends on common-lib 1.2.0)
worker-service (chart, depends on common-lib 1.2.0)
scheduler-service (chart, depends on common-lib 1.1.0)
frontend (chart, depends on common-lib 1.2.0)
common-lib (library chart, current version 1.3.0)
The scheduler-service was pinned to common-lib 1.1.0 because nobody had updated it. The library chart had a bug fix in 1.2.0 that the scheduler-service did not have. The bug manifested as an incorrect liveness probe path in the rendered template. The scheduler was being restarted by Kubernetes every five minutes because the probe was hitting the wrong endpoint.
Nobody noticed the version drift because Helm does not tell you. There is no helm outdated command. There is no dependency check. You have to manually inspect Chart.yaml for each chart and compare versions. The team had eight charts and three had drifted. The drift caused a production issue that took two hours to diagnose because nobody thought to check the library chart version.
Failure four: the rendered output is not in Git
This is the failure mode that matters most for GitOps. If you use ArgoCD or Flux, you have a Git repository that is the source of truth for your cluster state. ArgoCD reconciles the cluster to match Git. But if your Git repository contains Helm charts, ArgoCD has to render the charts to know what to deploy. The rendered output, the actual Kubernetes manifests, exist only at render time. They are not in Git.
This means the source of truth is not Git. The source of truth is Git plus the Helm rendering engine plus the values files plus the chart dependencies plus the Helm version. If any of those change, the rendered output changes, even if the Git diff is empty.
I worked with a team that had a production incident caused by a Helm version upgrade. They upgraded Helm from 3.12 to 3.14. A templating function had changed behavior. The toJson function in 3.14 produces different output for empty maps than in 3.12. Their config map, which had an empty map in the values, rendered differently. The application read the config map, parsed the JSON, and failed because the empty map was now {} instead of null. The Git diff was empty. The chart had not changed. The values had not changed. The Helm version had changed, and the rendered output was different.
With plain manifests in Git, this is impossible. The manifest in Git is the manifest in the cluster. There is no rendering step. ArgoCD applies what is in Git, and what is in Git is what you wrote.
What I replaced it with
I replaced Helm with Kustomize and plain Kubernetes manifests. The structure is simple:
my-service/
base/
deployment.yaml
service.yaml
configmap.yaml
kustomization.yaml
overlays/
dev/
kustomization.yaml
patch-resources.yaml
staging/
kustomization.yaml
patch-replicas.yaml
prod/
kustomization.yaml
patch-resources.yaml
patch-replicas.yaml
The base deployment.yaml is plain YAML. No templates. No functions. No rendering.
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-service
labels:
app: my-service
spec:
replicas: 2
selector:
matchLabels:
app: my-service
template:
metadata:
labels:
app: my-service
spec:
containers:
- name: my-service
image: ghcr.io/team/my-service:latest
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
env:
- name: STRIPE_SECRET_KEY
valueFrom:
secretKeyRef:
name: stripe-secret
key: secret-key
This is what gets deployed. This is what is in Git. This is what ArgoCD reads. There is no gap between what you wrote and what runs.
The kustomization.yaml in base is:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
The production overlay is:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-resources.yaml
- path: patch-replicas.yaml
And patch-replicas.yaml is:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-service
spec:
replicas: 6
The production resources patch:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-service
spec:
template:
spec:
containers:
- name: my-service
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 1000m
memory: 1Gi
The patch is a partial manifest. Kustomize merges it with the base. The merge is strategic, not deep, and the behavior is documented and predictable. You can look at the base, look at the patch, and know exactly what the result will be. You do not need to run a command to find out.
If you want to verify, you run:
kustomize build overlays/prod
And you get the full rendered manifest. You can diff it against staging:
diff <(kustomize build overlays/staging) <(kustomize build overlays/prod)
That diff is the entire difference between staging and production. It is readable. It is reviewable in a pull request. It is what ArgoCD will apply. There is no hidden merge logic, no template functions, no library chart dependencies.
What you lose, and why it does not matter
The argument for Helm is that it gives you reuse and configurability. You write one chart, you configure it with values, and you deploy it to multiple environments. This is true. Helm does give you that. The question is whether you need it.
Most startups have between three and ten services. Each service has one deployment, one service, one config map, maybe one ingress. That is four to five manifests per service. The base manifests are nearly identical across services. You do not need a templating engine to share them. You need a base directory and a copy command, or a Kustomize base and an overlay.
The configurability argument is the one I hear most. “We need different values for different environments.” Yes. You need different replica counts, different resource limits, different environment variables, and maybe different images. That is four patches. Kustomize patches are four small YAML files. They are more readable than a values file because they are explicit. A patch that sets replicas: 6 tells you exactly what production runs. A values file that sets replicas: 6 tells you what production runs unless the base has a different value, unless a command-line override exists, unless the template has a default function that changes the behavior.
The reuse argument matters for third-party software. I still use Helm charts for installing Prometheus, Grafana, cert-manager, and other platform tools. The Helm chart for the Prometheus stack is 2000 lines of templated YAML maintained by the community. I do not want to maintain that. I want to install it, configure it with a values file, and let someone else handle the updates. This is what Helm was built for, and this is where it should stay.
But for your own services, the chart is 100 lines of templates that you wrote, that you maintain, that only you use, and that create a rendering layer between you and your cluster state. That rendering layer is the problem. It adds indirection without adding value. It makes debugging harder, not easier. It makes the source of truth ambiguous, not clear.
The migration
I have done this migration five times. It takes about a day per service. The process is mechanical.
First, render the Helm chart for production:
helm template my-service ./my-service-chart \
-f values.yaml \
-f values-prod.yaml \
> prod-rendered.yaml
Second, review the rendered output. This is the step where you learn what your chart actually produces. In every migration I have done, the team found something in the rendered output they did not expect. A label they forgot about. A resource limit they thought was different. An env var that was empty because the values file did not have it and the template had no default.
Third, clean up the rendered output. Remove Helm-specific labels like app.kubernetes.io/managed-by: Helm. Remove the helm.sh/hook annotations if you have any. The hooks were probably not doing what you thought anyway.
Fourth, split the rendered output into base manifests and environment-specific patches. Put the base manifests in base/. Create overlays for each environment. Move the environment-specific differences into patches.
Fifth, deploy with ArgoCD pointing at the Kustomize overlay. ArgoCD supports Kustomize natively. You do not need to install anything. You point the ArgoCD Application at the overlay directory and it builds it.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-service-prod
spec:
source:
repoURL: https://github.com/team/infra
path: my-service/overlays/prod
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
Sixth, delete the Helm release:
helm uninstall my-service -n production
Then let ArgoCD reconcile. The cluster state does not change because the manifests are the same. The source of truth changes from Helm plus values plus rendering to Git plus Kustomize plus nothing.
The result
After the migration, every team I worked with reported the same things. Debugging became faster because they could read the manifests directly. Pull requests became more reviewable because the diff was the actual change, not a template change that might or might not produce a different output. Incidents decreased because the gap between what was in Git and what was in the cluster disappeared.
The Berlin team that could not agree on their resource limits now has one file to check: overlays/prod/patch-resources.yaml. It says cpu: 2000m, memory: 2Gi. That is what production runs. There is no merge, no template, no default function. There is one file, and it is the answer.
The Tallinn team that had library chart version drift now has a shared base directory. When the base changes, the diff is visible in every overlay that references it. There is no version number to track. There is no dependency to update. The base is a directory, and the overlays point at it. If the base changes, every overlay that uses it gets the change, and the diff is explicit in the pull request.
The Amsterdam team that spent forty minutes debugging a whitespace bug now writes YAML that is syntactically checked by their editor before it reaches Git. They have not had a template rendering bug since, because there are no templates.
When to keep Helm
I am not telling you to uninstall Helm. I am telling you to use it for what it was built for. Helm is a package manager. Use it to install packaged software. The bitnami, prometheus-community, and grafana charts are maintained by people who understand those projects deeply. Use their work. Do not reinvent it.
But stop writing Helm charts for your own services. Your service is not a package. It is a deployment that you control end to end. The configuration is yours. The manifests are yours. The environment differences are yours. You do not need a templating engine to manage four YAML files. You need four YAML files.
The strongest signal I can give you is this: if you cannot answer the question “what does your Helm chart render for production right now” without running a command, your source of truth is not Git. It is a build step. And build steps are things that break, drift, and produce different outputs on different days. Your cluster state should not depend on a build step. It should depend on a file that you wrote, that you reviewed, and that is sitting in Git exactly as it will be applied.
Plain manifests give you that. Kustomize gives you the overlay system to manage environments without templating. ArgoCD gives you the reconciliation to keep the cluster in sync with Git. None of these tools require a rendering step that hides the output from the review process.
The complexity you removed was not serving you. It was making your configuration harder to read, harder to debug, and harder to trust. The simplest tool that works is the one you can reason about at 2am. Helm charts are not that. Plain manifests are.