Skip to main content
DevOps Insightsguides & answers

Containers · intermediate

Helm Charts: Packaging Kubernetes Applications

Short answer

Helm is two things bolted together: a templating engine for Kubernetes YAML, and a record of what it installed so it can upgrade or roll back. The templating is what people learn first; the release record is what makes helm rollback possible and what causes the confusing failures.

N
Neeraj Jha
Updated 8 min read185 views
On this page · 11 sections
Diagram: a Helm chart's Chart.yaml, values.yaml and templates rendered into plain Kubernetes YAML and installed as a release.

Who this is for

You are copying the same Deployment and Service YAML between environments, changing three values each time. You do not need to write a chart to use one.

What a chart is

mychart/
  Chart.yaml         # name, version, appVersion
  values.yaml        # the defaults someone can override
  templates/
    deployment.yaml
    service.yaml
    _helpers.tpl     # named templates, no output of its own
  charts/            # dependencies, vendored
yaml
# Chart.yaml
apiVersion: v2
name: mychart
version: 1.4.2        # the chart's own version
appVersion: "2.1.0"   # the application it installs

Those two version fields are different and both matter. version changes when you change the chart; appVersion changes when the software changes. Bumping only appVersion after editing a template is how a repository ends up with two different charts claiming to be 1.4.2.

Templates

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "mychart.fullname" . }}
  labels:
    {{- include "mychart.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Three mechanics that account for most template debugging:

{{- trims preceding whitespace. The blank lines a conditional leaves behind are harmless, but YAML is indentation-sensitive, so a line at the wrong depth — or two lines joined because a {{- ate the newline between them — is a parse error rather than a cosmetic problem.

nindent indents the whole block it emits. toYaml .Values.resources produces multiple lines, and every one of them needs the indentation — indent pads every line, the first included, but the first line lands wherever the tag sits — usually after a key on the same line, so the padding ends up mid-line; nindent adds a newline first, so every line starts where it should. Using the wrong one is usually a parse error, not a cosmetic one.

required makes a missing value a build error rather than an empty string:

yaml
host: {{ required "ingress.host must be set" .Values.ingress.host }}

Without it, a typo'd value renders as nothing, and you deploy an Ingress with an empty host that matches everything.

Values, and which one wins

bash
helm upgrade --install web ./mychart \
  -f values-production.yaml \
  --set image.tag=2.1.4

Precedence, lowest to highest: the chart's values.yaml, then each -f file in the order given, then --set. Later wins, and merging is per-key rather than per-file — so a -f file only needs the keys it changes.

One trap: lists are replaced, not merged. Overriding a single entry of a list means restating the whole list.

The commands that matter

bash
helm template web ./mychart -f values-production.yaml   # render locally, apply nothing
helm upgrade --install web ./mychart -f values-production.yaml --rollback-on-failure --timeout 5m
helm diff upgrade web ./mychart -f values-production.yaml   # plugin, well worth installing
helm history web
helm rollback web 3

helm template is the one to reach for first. It renders to stdout with no cluster involved, which turns "why did it deploy that" into something you can read. Pipe it through the manifest analyzer to check what you are about to apply.

upgrade --install is idempotent: install if absent, upgrade if present. It is what belongs in a pipeline, rather than branching on whether the release exists.

--rollback-on-failure (called --atomic before Helm 4, which still accepts the old name with a deprecation warning) is the flag most worth adopting. It waits for resources to become ready and rolls back automatically if they do not, so a failed upgrade leaves the previous working release running instead of a half-applied one. It turns on --wait, so always give it a --timeout.

Upgrading to Helm 4

Added 29 October 2026. --rollback-on-failure above is a Helm 4 name, but the upgrade changes more than names. Checked against Helm's issues and release notes up to 4.3.1:

  • Null overrides can stop working. Setting a subchart key to null in your values is Helm's documented way to delete a subchart default. From 4.2.0 that null is stripped when no -f or --set value touches the subchart's scope, so the default comes back. It was still open in 4.3.1 when checked on 21 October.
  • Empty entries vanish. A key written as one: with nothing after it is removed from .Values from 4.2.0, so a range over that map skips it. Writing one: {} should keep it (read in Helm's source, not run).
  • --wait can hang on custom resources such as Argo Rollouts, until the timeout. --wait=legacy uses Helm 3's polling. --rollback-on-failure turns waiting on, so it is affected too.
  • Two loud changes: --post-renderer takes the name of a plugin, not a script, and helm registry login takes the host name only.

Before switching, render the chart with both versions and compare (helm3 and helm4 here stand for the two binaries):

bash
helm3 template web ./mychart -f values-production.yaml > rendered-v3.yaml
helm4 template web ./mychart -f values-production.yaml > rendered-v4.yaml
diff -u rendered-v3.yaml rendered-v4.yaml

Every item, marked as documented or as a regression with its issue, is on Helm 3 to Helm 4.

Where the release record bites

Helm stores each release's state in a Secret in the release namespace. That is how history and rollback work — and it is why two surprising things happen.

"cannot re-use a name that is still in use" means a release record exists, even if its resources are gone. helm list -a shows it; helm uninstall clears it.

A release stuck pending-upgrade happens when a helm upgrade is interrupted — CI cancelled, laptop closed — leaving the record mid-transition. Nothing will proceed until it is resolved: roll back to the last good revision, and avoid deleting the Secret by hand unless you understand what you are discarding.

Resources Helm did not create are not managed. If somebody kubectl apply'd something first, Helm will refuse to adopt it rather than overwrite it.

Common mistakes

  1. --set for secrets. It goes into shell history, into CI logs, and into the release record. A -f file keeps it out of history and logs but not out of the release record, which stores every value you pass. Keep secrets out of Helm values entirely and have the chart reference a Secret that something else manages.
  2. Editing a Helm-managed resource with kubectl. The next upgrade restores the chart's version, so the fix disappears without explanation.
  3. No resource requests or limits in values.yaml. The chart installs fine, then behaves unpredictably under load — see requests vs limits.
  4. :latest as the image tag. Nothing in the rendered manifest changes between releases, so helm rollback rolls back to the same image.
  5. Trusting helm lint as validation. It checks the chart is well formed, not that the rendered Kubernetes objects are correct.

If this did not work

  • "parse error … did not find expected key". Rendering produced invalid YAML. helm template the chart and read the output around that line — it is nearly always indent where nindent was needed.
  • The release says deployed and nothing is running. Helm reports on applying manifests, not on the workload being healthy. Without --wait or --rollback-on-failure it will happily report success for a Deployment that cannot pull its image — ImagePullBackOff.
  • A value in values.yaml has no effect. It is probably being overridden by a -f file or --set; helm get values web shows the overrides the release was given, and helm get values web --all the full set it actually used.
  • A subchart ignores your value. Subchart values are namespaced under the subchart's name: postgresql.auth.password, not auth.password.

Next steps

Take one application you deploy with raw YAML and run helm create to scaffold a chart around it, then helm template it and diff the output against your existing manifests. The differences are the defaults the scaffold assumed, and reading them is a fast way to learn what charts normally parameterise.

Mentioned in this article

Tagged with

Enjoyed this article?

Get more DevOps insights delivered to your inbox.

Get new posts by email

Subscribe to get an email when a new blog post is published. Skip anytime.

No spam, unsubscribe anytime.

N

Written by

Neeraj Jha

Platform administrator and lead writer.

View all posts →

Discussion

0 comments

Sign in to join the conversation.

Be the first to comment

Start a conversation about this post

Share: