You Don’t Need Helm the Way You Think You Do
Helm has this weird gravity in the Kubernetes space. Everyone treats it like it’s the package manager for Kubernetes, capital T, capital K. But honestly? Most home lab setups and even plenty of production deployments treat Helm like hiring a forklift to move a couch. Technically it works. Your neighbors will have questions.
Helm is useful. But the moment you start writing your third Sprig function and nesting values five levels deep, you’ve crossed from “elegant templating” into “why am I debugging YAML with a spreadsheet.”
This is about thinking clearly about when Helm makes sense, how to keep it sane when you use it, and when to just write regular Kubernetes manifests and call it a day.
The Helm Seduction
Before we talk solutions, let’s diagnose the problem. Helm sells itself as three things:
- Package management: reusable, versioned charts
- Templating: turn manifests into parameterized things
- Release management: track what’s deployed, rollbacks, hooks
Most people reach for Helm for reason #2. They think: “I have 47 nearly-identical Deployments, I could template this.” And yeah, you could. But then you end up with:
services: - name: app-a image: myapp:1.0 replicas: 3 env: DATABASE_HOST: postgres.default.svc.cluster.local LOG_LEVEL: info resources: requests: memory: "256Mi" cpu: "100m" - name: app-b image: myapp-worker:1.0 replicas: 2 env: QUEUE_HOST: redis.default.svc.cluster.local LOG_LEVEL: debug resources: requests: memory: "512Mi" cpu: "250m" # ... 45 moreAnd then your template does:
{{- range .Values.services }}apiVersion: apps/v1kind: Deploymentmetadata: name: {{ .name }}spec: replicas: {{ .replicas }} template: spec: containers: - name: {{ .name }} image: {{ .image }} env: {{- range $key, $val := .env }} - name: {{ $key }} value: {{ $val | quote }} {{- end }} resources: requests: memory: {{ .resources.requests.memory }} cpu: {{ .resources.requests.cpu }}---{{- end }}Congratulations, you’ve automated the creation of problems instead of the creation of manifests. Now every environment-specific value needs a comment explaining which service it applies to. Your team needs a Helm values spreadsheet. Your 2 AM self is crying.
The Right Way to Think About Values
Treat Helm values as configuration intent rather than as generic variables. The difference matters.
A variable is generic: “this could be anything.” Configuration is specific: “this is how this deployment differs from the template baseline.”
When you design a values file, ask: What actually changes between deployments?
For a real example: if you’re deploying the same app to staging and production, what’s different?
- Image tag (staging:
main-latest, prod:v1.2.3) - Replicas (staging: 1, prod: 3)
- Resource limits (staging: small, prod: larger)
- External URLs (staging: staging.example.com, prod: example.com)
- TLS cert sources (staging: self-signed, prod: LetsEncrypt)
Those are values. Everything else? Default it.
A sane values file looks like this:
# values.yaml - Defaults are production-readyreplicaCount: 3image: repository: myapp tag: v1.2.3
ingress: enabled: true host: example.com tls: enabled: true issuer: letsencrypt-prod
resources: requests: memory: "512Mi" cpu: "250m" limits: memory: "1Gi" cpu: "500m"
# Override in values-staging.yamlAnd values-staging.yaml:
replicaCount: 1image: tag: main-latest
ingress: host: staging.example.com tls: issuer: letsencrypt-staging
resources: requests: memory: "128Mi" cpu: "50m" limits: memory: "256Mi" cpu: "100m"That’s it. No deep nesting. No Sprig magic. Just “staging is different from prod in these five ways.”
Deploy with:
helm install myapp ./chart -f values-staging.yamlYou don’t pass the chart’s own values.yaml. Helm loads ./chart/values.yaml automatically as the base layer, and each -f you add merges on top of it, left to right. Passing -f values.yaml explicitly either duplicates that base or, if you’re standing in a different directory, fails on a path that doesn’t exist. Keep the defaults in the chart and pass only the overrides.
One thing that surprises people: the merge is a deep merge for maps but a straight replacement for lists. Override one entry of a five-item tolerations list in a staging file and you get a one-item list, not five with one changed. That is the single most common way a values override does something you didn’t intend.
Template Sanity: The Golden Rules
If you’re going to use Helm templates, follow these rules or your sanity will be a casualty.
Rule 1: Don’t template what doesn’t need templating.
Seriously. If a field never changes, hardcode it.
# BadapiVersion: {{ .Values.apiVersion | default "apps/v1" }}kind: {{ .Values.kind | default "Deployment" }}
# GoodapiVersion: apps/v1kind: DeploymentRule 2: One conditional per file, wrapping the whole file.
Helm has no way to include or exclude a template file from Chart.yaml. There is no templates: list you can filter, and a values key set to false does not make Helm skip a manifest that references it. Every file in templates/ gets rendered, every time. The only lever you have is a Go template conditional inside the file.
So use exactly one, at the top, wrapping everything:
{{- if .Values.ingress.enabled }}apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: {{ .Release.Name }}spec: ingressClassName: {{ .Values.ingress.className }} rules: - host: {{ .Values.ingress.host }} http: paths: - path: / pathType: Prefix backend: service: name: {{ .Release.Name }} port: number: 80{{- end }}One resource per file, one flag per file. When ingress.enabled is false the file renders to nothing, and Helm drops empty documents from the release. That is the whole mechanism.
templates/ deployment.yaml # no conditional, always rendered service.yaml # no conditional, always rendered ingress.yaml # one {{- if .Values.ingress.enabled }} wrapping the file configmap.yaml # one {{- if .Values.config }} wrapping the fileWhat you’re avoiding is the other shape: a single 200-line deployment.yaml with eight {{- if }} blocks threaded through it, where the indentation of an env: entry depends on which branch you’re in. That file is where charts go to die. Splitting resources into files gets you the same flexibility with conditionals you can actually see.
Rule 3: Two levels of nesting. Three when Kubernetes made you.
# Good: one level of groupingimage: repository: myapp tag: v1.0
# Acceptable: three keys deep, but only because the Kubernetes# resource spec is shaped that way and mirroring it is clearerresources: requests: memory: "256Mi"
# Pure chaos (stop here)config: app: features: auth: providers: oidc: clientId: "..."When you need deep config, use a ConfigMap with a file, not nested values:
configMap: app-config.yml: | auth: providers: oidc: clientId: "..."Rule 4: Keep Sprig to formatting, not decisions.
Sprig ships over 200 functions into every Helm template. It’s powerful. It’s also a black hole that swallows time and sanity.
Three examples, in descending order of how much they’ll hurt you:
# Actively broken: `now` changes on every render, so `helm diff` shows# a change every time and every upgrade restarts your pods.- name: TIMESTAMP value: {{ now | date "2006-01-02" }}
# A decision hiding in a template. Fine once. Bad as a habit.- name: LOG_LEVEL value: {{ if .Values.debug }}debug{{ else }}info{{ end }}
# Legitimately good. `default` and `quote` are formatting, not logic.- name: PORT value: {{ .Values.port | default 8080 | quote }}default, quote, toYaml, nindent and required are the ones worth knowing, and they’re all doing presentation. The moment a function is choosing what to deploy rather than how to render it, that decision belongs in the values file where someone can read it without running helm template.
So instead of branching in the template:
logLevel: "info" # set to "debug" in values-staging.yamlport: "8080"Then the template is just:
- name: LOG_LEVEL value: {{ .Values.logLevel | quote }}- name: PORT value: {{ .Values.port | quote }}Helm Linting: Actually Catch Mistakes
Before you deploy, lint your chart:
helm lint ./chartThis checks chart structure, that Chart.yaml is sane, and that the templates render at all. It does not check whether the rendered output is valid Kubernetes. For that you want the API server’s opinion:
helm template myapp ./chart -f values-staging.yaml \ | kubectl apply --dry-run=server --validate=strict -f -Use --dry-run=server, not --dry-run=client. Server-side dry run sends the manifests to the API server, which runs the real schema validation, applies defaulting, and runs admission webhooks, then throws the result away instead of persisting it. Client-side dry run does less and needs a reachable cluster anyway, so there is no reason to prefer it here.
Be clear about what this catches and what it does not. It catches misspelled fields, wrong types, a replicas: "3" string where an integer belongs, an invalid apiVersion, and a resource your admission policies would reject. It does not catch a Deployment mounting a Secret that doesn’t exist, a bad image tag, or a probe pointed at the wrong port. Nothing resolves those until a pod actually tries to start.
If you want the check without kubectl in the pipe, helm template --validate does the same server-side round trip.
Make it a pre-commit hook:
#!/usr/bin/env bashset -euo pipefailhelm lint ./chartfor v in values.yaml values-staging.yaml; do helm template myapp ./chart -f "$v" \ | kubectl apply --dry-run=server --validate=strict -f - >/dev/nulldoneRun it against every values file you ship, not just the default one. A chart that renders cleanly with production values and explodes with staging values is the normal failure, because staging is where the flags get flipped.
When to Skip Helm Entirely
The controversial part: most home labs don’t need Helm.
Use raw kubectl with kustomize or plain YAML if:
- You have fewer than 10 deployments
- Your deployments don’t share much structure
- You’re the only person maintaining things
- You’re not publishing a reusable chart for others
Example: use kustomize instead.
k8s/ base/ deployment.yaml service.yaml kustomization.yaml overlays/ staging/ kustomization.yaml production/ kustomization.yamlDeploy with:
kubectl apply -k k8s/overlays/productionKustomize patches YAML instead of templating it. Simpler, easier to debug, and your manifests stay readable on their own.
For a tiny home lab running Nextcloud and a database? Just write the YAML by hand. Helm is overkill.
The Decision Tree
Need to pick a deployment tool? Work down this list:
-
Is this a chart you’re publishing to Artifact Hub or your team’s internal registry? → Use Helm. Package it. Version it. Document the values.
-
Are you deploying the same app across multiple environments (staging, prod, multi-region)? → Use Helm with environment-specific values files. Or use Kustomize with overlays. Either works.
-
Is this a one-off deployment you’re managing yourself? → Write YAML. Use
kubectl apply -f. Add comments. Move on. -
Do you have 30+ services with mostly-identical structure? → Use Helm, but keep values flat. Or rethink your architecture: maybe you need a platform abstraction, not a template language.
-
Are you writing deeply-nested Sprig functions to glue values together? → Stop. You’ve already lost. Simplify or switch tools.
The Sanity Checklist
Before you ship a Helm chart:
- Your values file is readable without a spreadsheet
- Template files never exceed 100 lines
- No Sprig function chains longer than one
| - At most one
{{- if }}per template file, and it wraps the whole file - No
now,randAlphaNumor anything else that changes between renders - Values are nested no deeper than the
resources.requests.memoryshape - You can explain the purpose of each value in one sentence
-
helm lintpasses -
helm templatepiped tokubectl apply --dry-run=serverpasses, for every values file you ship - A junior engineer could override a single value without breaking things
If you fail any of these, the chart is too complex. Simplify it. Split it. Or ditch it.
The Bottom Line
Helm is a tool, not a religion. It solves a real problem: packaging and parameterizing Kubernetes deployments. But the problem it solves is narrower than most teams think.
Use Helm when you’re packaging reusable charts. Use Kustomize when you’re managing environment variations. Use plain YAML when you’re just deploying a thing.
And whatever you choose, keep it simple. Your 2 AM self will thank you.
Common Questions
Can I use Helm and Kustomize together?
Yes. Run helm template to render the chart, then feed the output to Kustomize as a base, or use Kustomize’s helmCharts field to inflate a chart inside a kustomization. It works, and it’s the usual way to patch a third-party chart that doesn’t expose the value you need. You lose helm rollback, because Kustomize applies the manifests directly.
How do I see what a chart will actually deploy?
Run helm template <name> ./chart -f values.yaml to print the rendered manifests without touching the cluster. For an upgrade, install the helm-diff plugin and run helm diff upgrade <release> ./chart -f values.yaml, which shows only what changes against the live release. Read the diff before every upgrade of anything stateful.
Does Helm 2 or Tiller still matter?
No. Helm 2 reached end of life in November 2020 and Tiller, the in-cluster server component that made Helm 2 a security problem, was removed in Helm 3. Any tutorial mentioning helm init or Tiller predates 2020. Helm 3 talks to the API server directly with your own kubeconfig credentials.
Where does Helm store release state?
In Secrets in the release’s namespace, named sh.helm.release.v1.<release>.v<revision>. That’s why helm list only shows releases in your current namespace and why deleting a namespace destroys the release history with it. Each revision is a full gzipped copy of the rendered manifests, which is also why a chart with a huge ConfigMap can hit the 1MB Secret size limit.
Should I commit my values files to Git?
Yes, all of them except secrets. Values files are the record of what’s deployed where, and they’re useless outside version control. Keep passwords and tokens out with Sealed Secrets, External Secrets Operator, or --set from a CI secret store, and never in a committed values-prod.yaml.