Helm gets pitched as "a package manager for Kubernetes," which undersells how much of it is really a templating engine with strong opinions about YAML merging. Most of the pain teams hit isn't with Helm's install/upgrade lifecycle — it's with values resolution and Go template syntax producing something subtly different from what was intended. Here's what actually trips people up.
Values merge, they don't override wholesale
The most common surprise is that values.yaml overrides merge at the map level, not by replacing entire blocks. If your chart's default values define a resources block with both requests and limits, and your override only sets limits.memory, the default requests survives — but only because maps merge recursively. Lists do not merge; they replace entirely.
# chart defaults (values.yaml)
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
# your override (prod-values.yaml)
resources:
limits:
memory: 512MiResult: requests is untouched, limits.cpu is untouched, only limits.memory changes. Now compare that to a list field like env:
# override replaces the ENTIRE env list, not just one entry
env:
- name: LOG_LEVEL
value: debugIf the chart default had five environment variables defined, this override leaves you with exactly one. That's a frequent source of "why did my other env vars disappear" incidents after a values file change.
Template functions run before Kubernetes ever sees YAML
Helm renders templates client-side into plain YAML before anything is sent to the API server, which means whitespace and indentation bugs in templates produce YAML errors that have nothing to do with Kubernetes semantics. The nindent and indent functions exist specifically because Go templates don't understand YAML structure — they're just string substitution.
{{- if .Values.podAnnotations }}
metadata:
annotations:
{{- toYaml .Values.podAnnotations | nindent 4 }}
{{- end }}Forgetting the - in {{- leaves stray blank lines and, depending on context, can shift indentation just enough to make the block invalid YAML or attach to the wrong parent key. Run helm template . --debug before every meaningful change — it's the fastest way to see exactly what will be applied, without touching a cluster.
Subcharts and the values scoping trap
When a chart depends on subcharts, top-level values in the parent's values.yaml are not automatically visible to subchart templates. You have to nest them under the subchart's name:
# parent values.yaml
postgresql:
auth:
database: myapp
primary:
persistence:
size: 20GiGlobal values are the one exception — anything under a top-level global: key is passed down to every subchart, which makes global the right place for things like image pull secrets or a shared environment label, but a bad place for chart-specific configuration since every subchart receives it whether it uses it or not.
Testing before you ship
helm lint catches structural problems, but it won't catch semantic mistakes like a mistyped values path that silently resolves to nil instead of erroring. Use required for values that must be set:
image:
repository: {{ required "image.repository is required" .Values.image.repository }}Combine that with helm template --values prod-values.yaml | kubectl apply --dry-run=server -f - in CI, and most of the classic Helm surprises get caught before they reach a real cluster instead of during an incident.