Skip to content
Go back

Helm Without the YAML Soup

By SumGuy 13 min read
Helm Without the YAML Soup
Contents

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:

  1. Package management: reusable, versioned charts
  2. Templating: turn manifests into parameterized things
  3. 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:

values.yaml
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 more

And then your template does:

{{- range .Values.services }}
apiVersion: apps/v1
kind: Deployment
metadata:
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?

Those are values. Everything else? Default it.

A sane values file looks like this:

# values.yaml - Defaults are production-ready
replicaCount: 3
image:
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.yaml

And values-staging.yaml:

replicaCount: 1
image:
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:

Terminal window
helm install myapp ./chart -f values-staging.yaml

You 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.

# Bad
apiVersion: {{ .Values.apiVersion | default "apps/v1" }}
kind: {{ .Values.kind | default "Deployment" }}
# Good
apiVersion: apps/v1
kind: Deployment

Rule 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:

templates/ingress.yaml
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
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 file

What 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 grouping
image:
repository: myapp
tag: v1.0
# Acceptable: three keys deep, but only because the Kubernetes
# resource spec is shaped that way and mirroring it is clearer
resources:
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:

values.yaml
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:

values.yaml
logLevel: "info" # set to "debug" in values-staging.yaml
port: "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:

Terminal window
helm lint ./chart

This 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:

Terminal window
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:

.git/hooks/pre-commit
#!/usr/bin/env bash
set -euo pipefail
helm lint ./chart
for v in values.yaml values-staging.yaml; do
helm template myapp ./chart -f "$v" \
| kubectl apply --dry-run=server --validate=strict -f - >/dev/null
done

Run 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:

Example: use kustomize instead.

k8s/
base/
deployment.yaml
service.yaml
kustomization.yaml
overlays/
staging/
kustomization.yaml
production/
kustomization.yaml

Deploy with:

Terminal window
kubectl apply -k k8s/overlays/production

Kustomize 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:

  1. 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.

  2. 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.

  3. Is this a one-off deployment you’re managing yourself? → Write YAML. Use kubectl apply -f. Add comments. Move on.

  4. 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.

  5. 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:

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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
ArgoCD for Home Lab GitOps
Next Post
k3s Cluster on 3 Mini PCs From Zero

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts