Kubernetes describes an application as a set of YAML objects: a Deployment, a Service, a ConfigMap, perhaps an Ingress and a ServiceAccount. Deploy the same application to three environments and you have three nearly identical sets of files that drift apart. Upgrade it and you need to know which objects belong to which version. Remove it and you need to find every object it created. Helm is the tool that answers those three problems with templates, values and a stored release history.
This article builds Helm up from the ground: what a chart is, how rendering works, how values combine, what Helm stores in your cluster, and how upgrades and rollbacks behave when things fail. The commands are written for Helm 4, with the Helm 3 differences noted. If Deployments and Services are new to you, start with the Kubernetes introduction first.
The problem Helm solves
Without Helm, teams usually start by copying manifests per environment, then reach for search and replace, then write a shell script around kubectl. Each step works until two people change the same values differently. Helm replaces this with three ideas.
- A chart is a versioned package of templated manifests plus default settings. It is the unit you share and publish.
- Values are the settings for one installation: image tag, replica count, resource limits, hostnames. The same chart with different values gives you dev, staging and production.
- A release is one installation of a chart in a namespace, with a name and a numbered history of revisions. Upgrades create new revisions, and rollback re-applies an older one.
Helm is a client-side tool. Since Helm 3 there is no in-cluster server component: the helm binary renders manifests locally and talks to the Kubernetes API with your credentials, so it can do exactly what your kubeconfig allows and nothing more.
Helm 4 and Helm 3: which one you are running
Helm 4 was released in November 2025. According to the Helm project's end-of-life announcement, the last Helm 3 minor release was scheduled for 9 September 2026 and Helm 3 receives security fixes only until 10 February 2027. New work should use Helm 4, and helm version tells you which binary your CI image contains.
Most chart and command knowledge carries over, but a few things changed that matter to beginners following older tutorials. In the Helm 4.3 reference, the install and upgrade flag that rolls back on failure is --rollback-on-failure, where Helm 3 used --atomic, and the flag that forces replacement of resources is --force-replace, where Helm 3 used --force. Helm 4 applies objects with Kubernetes server-side apply: --server-side defaults to true on install and to auto on upgrade, which keeps a release on the method its previous revision used, so releases created by Helm 3 continue with client-side apply. The --wait flag now takes a strategy: watcher, hookOnly or legacy.
Anatomy of a chart
Run helm create web and you get a working chart to read. Strip it down and the essential layout is small.
web/
Chart.yaml # name, chart version, appVersion, dependencies
values.yaml # default values, the chart's public interface
templates/
_helpers.tpl # named templates; files starting with _ render nothing
deployment.yaml
service.yaml
configmap.yaml
NOTES.txt # printed after install
crds/ # optional: CustomResourceDefinitions, installed once
charts/ # dependencies, fetched by helm dependency updateChart.yaml carries two versions that are easy to confuse. version is the chart's own semantic version and must change whenever the chart changes; appVersion is informational and usually names the application release the chart deploys. Treat values.yaml as an API: every key in it is something users will set, so name keys clearly, comment them and avoid renaming them casually.
Templates: Go templates with a Kubernetes flavour
Templates use Go's text/template language plus the Sprig function library. Expressions sit inside double braces, and Helm provides built-in objects such as .Values, .Release (name, namespace, revision), .Chart and .Capabilities (the cluster's Kubernetes version and APIs).
{{/* templates/_helpers.tpl */}}
{{- define "web.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "web.fullname" . }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
containers:
- name: web
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
ports:
- containerPort: {{ .Values.service.targetPort }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}Four idioms in this template are worth learning early. The dash in {{- trims whitespace, which matters because the output is YAML and indentation is syntax. include calls a named template and, unlike template, returns a string you can pipe into nindent to indent it correctly. toYaml renders a whole values subtree, so users can pass any resources block without the chart listing every field. The checksum/config annotation hashes the rendered ConfigMap into the Pod template, so a config change changes the Pod template and triggers a rolling restart; without it, Pods keep running with stale configuration.
Use required for values that have no safe default, such as {{ required "image.repository is required" .Values.image.repository }}, so a missing setting fails at render time instead of producing an invalid object.
Values: where settings come from and who wins
Helm merges values from several sources, from lowest to highest precedence: the chart's own values.yaml; for a subchart, the parent chart's values under the subchart's key; files passed with -f, in the order given, so a later file overrides an earlier one; and individual --set flags. Maps merge key by key, but lists are replaced whole, which surprises people who expect to append one item to a list of environment variables.
A common layout keeps defaults in the chart and one small file per environment in the deploying repository, such as values-prod.yaml with only the keys that differ. Avoid long chains of --set in CI: they are hard to review, and type coercion can turn a tag like 1.10 into a number. Use --set-string or a file when type matters.
On upgrade, Helm uses the chart's defaults plus whatever you pass this time. --reuse-values instead starts from the last release's values, which seems convenient but ignores keys added to the new chart version's defaults and can make templates fail on missing values. If you need the old overrides, --reset-then-reuse-values starts from the new chart defaults and then applies the previous release's values. The most predictable practice is to pass the full set of value files every time.
Worked example: install, upgrade, roll back
The cycle below is the one to practise until it is routine. Render first, apply second, inspect third.
helm create web
helm lint web
helm template web ./web -f values-prod.yaml > /tmp/rendered.yaml # no cluster needed
helm upgrade --install web ./web -n shop --create-namespace \
-f values-prod.yaml --wait --timeout 5m --rollback-on-failure
helm list -n shop
helm history web -n shop # revisions, status and chart version per revision
helm get values web -n shop # user-supplied values for the current revision
helm get manifest web -n shop # exactly what was applied
# Change the image tag and upgrade: revision 2
helm upgrade web ./web -n shop -f values-prod.yaml --set image.tag=1.4.2 --wait
# Something is wrong: roll back to revision 1, which creates revision 3
helm rollback web 1 -n shop --waithelm upgrade --install is idempotent, which makes it the right command for CI: it installs when the release does not exist and upgrades when it does. helm template renders without touching a cluster, so you can diff output in code review or validate it with a schema checker. A rollback never rewrites history; it creates a new revision whose content matches an older one, so the history always tells the true story. To see what an upgrade would change before running it, the third-party helm-diff plugin compares the rendered output with the live release; it is not part of Helm itself.
What a release is inside the cluster
By default Helm stores each revision as a Kubernetes Secret in the release namespace, named sh.helm.release.v1.<release>.v<revision>. The Secret holds the chart, the values and the rendered manifest, compressed and encoded. Listing them with kubectl get secrets -n shop -l owner=helm shows the history Helm reads for helm history and helm rollback.
Three consequences follow. Anyone who can read Secrets in the namespace can read your values, so do not put real credentials in values files; use an external secret store or a sealed-secret approach. History grows with every upgrade, and --history-max, which defaults to 10 on upgrade in Helm 4.3, prunes old revisions; set it explicitly so storage stays bounded. And because the release record lives in the namespace, deleting the namespace deletes Helm's memory of the release along with the objects.
A release also has a status. If a helm process is killed mid-upgrade, for example by a CI timeout, the latest revision can be left in pending-upgrade and every later command fails with an error saying another operation is in progress. The usual recovery is helm rollback to the last revision whose status is deployed; deleting the stuck revision's Secret is a last resort that you should only take after reading helm history.
Hooks, ordering and CRDs
Helm applies the objects of a release together, ordered by kind, so Namespaces and ServiceAccounts go before Deployments. When you need an action at a specific moment, such as a database migration before the new version starts, use a hook: a normal manifest, typically a Job, annotated with helm.sh/hook: pre-upgrade. Hooks run at defined points (pre-install, post-install, pre-upgrade, post-upgrade, pre-delete, post-delete, pre-rollback, post-rollback and test), are ordered by helm.sh/hook-weight, and are cleaned up according to helm.sh/hook-delete-policy. Hook resources are not tracked as part of the release, so helm uninstall does not remove them; with no delete policy, Helm applies before-hook-creation, which deletes the old hook only when the next one runs. Add hook-succeeded or a Job TTL to clean up completed Jobs.
CustomResourceDefinitions in the crds/ directory are special. Helm installs them on first install and then never upgrades or deletes them, because deleting a CRD deletes every custom resource of that type across the cluster. Plan CRD upgrades as a separate, deliberate step.
Distributing charts
Charts are packaged as versioned .tgz archives with helm package. The modern way to share them is an OCI registry, the same kind of registry that stores container images: helm push web-0.1.0.tgz oci://registry.example.com/charts, then helm install web oci://registry.example.com/charts/web --version 0.1.0. Pin chart versions in every deploy; an unpinned install picks up whatever was published last. For dependencies, list them in Chart.yaml, run helm dependency update, and commit the lock file so builds are reproducible.
In a GitOps setup the cluster pulls instead of CI pushing. Argo CD, for example, renders a Helm chart itself and applies the result, so the release history lives in Git rather than in Helm Secrets; the Argo CD introduction explains how that changes the lifecycle. On managed Kubernetes, the same charts work unchanged; managed Kubernetes covers what the provider runs for you.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Another operation is in progress | Killed upgrade left pending status | Roll back to last deployed revision |
| Config changed but Pods did not restart | No checksum annotation | Hash the ConfigMap into the Pod template |
| Upgrade fails: invalid ownership metadata | Objects created outside Helm | Adopt deliberately with --take-ownership, or rename |
| Upgrade succeeds, app is down | No readiness wait | Use --wait with real readiness probes |
| Template nil pointer on upgrade | --reuse-values missed new defaults | Pass full value files every time |
| Field conflicts after switching tools | Server-side apply ownership | Read the conflict; --force-conflicts only when intended |
| Completed hook Jobs pile up | Hooks are not release-managed | hook-succeeded delete policy or a Job TTL |
Trade-offs
Helm's strength is packaging: a chart with values is easy to publish, version and install many times, which is why most open-source Kubernetes software ships one. Its weakness is that text templating of YAML is fragile; large charts become hard to read, and a misplaced indent produces valid-looking but wrong output. Kustomize, built into kubectl, patches plain YAML with overlays and has no release history, which suits in-house services with a few environments. Many teams use both: Helm for third-party software, overlays or a small chart for their own. Whatever you choose, keep the rendered output reviewable, because the manifest that reaches the API server is the only thing that matters.
What to do next
- Install Helm 4, run
helm createand read every generated file before changing anything. - Render with
helm templateand compare the output with the manifests you would have written by hand. - Deploy with
helm upgrade --install,--waitand--rollback-on-failure, then practisehelm historyandhelm rollback. - Add the checksum annotation, a
requiredcheck and readiness probes to your chart. - Set
--history-maxexplicitly and keep secrets out of values files. - Publish the chart to an OCI registry and pin its version in every deployment.