Helm
Helm is the package manager for Kubernetes. A chart is a templated bundle of manifests, an install of a chart into a cluster is a release, and each change produces a numbered revision you can roll back to. Helm 3 and later talk to the cluster directly (no more Tiller). Everything below was rechecked with helm v4.3.1 and v3.22.1, and every local recipe runs without a cluster. Where Helm 4 renamed a flag, it says so.
A chart’s structure
helm create mychart
scaffolds:
mychart/
Chart.yaml # name, version, appVersion
values.yaml # default values, overridable at install time
.helmignore
charts/ # vendored subcharts, empty at first
templates/
deployment.yaml
service.yaml
serviceaccount.yaml
ingress.yaml
httproute.yaml # Gateway API route, disabled by default
hpa.yaml
NOTES.txt # message printed after install
_helpers.tpl # reusable template snippets
tests/
test-connection.yaml # a pod run by: helm test <release>
Templates are Go templates; {{ .Values.foo }} pulls from values.yaml (or from overrides at install time).
Two files in that scaffold are opt-in rather than always applied, in two different ways.
httproute.yaml is the Gateway API counterpart of the ingress and stays behind
httpRoute.enabled: false in values.yaml, so a plain render omits it entirely.
templates/tests/ holds manifests annotated "helm.sh/hook": test: helm template still prints
them, but being hooks they are not created on install, only when you call helm test <release>
against a live cluster.
Render and lint locally (no cluster needed)
This is the fast feedback loop while writing a chart:
helm lint mychart # static checks
helm template myrelease mychart # render manifests to stdout, no cluster
helm template myrelease mychart -s templates/deployment.yaml # one file only
helm template myrelease mychart --set replicaCount=3 # override a value
helm template myrelease mychart -f prod-values.yaml # override with a file
helm show chart mychart # Chart.yaml metadata
helm show values mychart # the default values
helm template is the honest way to see exactly what will be applied before touching a cluster. It
renders entirely offline, which is also its limit: it never checks the result against the cluster’s
schema, and anything the chart asks about the cluster comes back with a default. A chart that gates
a resource on the Kubernetes version or on an installed API sees helm’s own built-ins unless you say
otherwise:
helm template myrelease mychart \
--kube-version 1.31.0 \
--api-versions gateway.networking.k8s.io/v1
Values: where they come from, who wins
Precedence, from weakest to strongest: the chart’s values.yaml, then each -f file in the order
given (right-most wins), then the command line.
helm template r mychart -f base.yaml -f prod.yaml --set image.tag=1.4.2
helm template r mychart --set-string image.tag=1234 # keep it a string, --set would make it an int
helm template r mychart --set-json 'ports=[80,443]'
helm template r mychart --set-file ca=./ca.pem # value read from a file
Merging is per key for maps, but a list is replaced wholesale: a prod.yaml that redefines
tolerations with one entry wipes the three in base.yaml, it does not append to them.
--set has its own mini-syntax where commas separate assignments and dots separate levels, so both
need escaping when they belong to the key or to the value:
helm template r mychart --set 'image.tag=a\,b'
helm template r mychart --set 'nodeSelector.kubernetes\.io/os=linux'
Dependencies and subcharts
A dependency is declared in Chart.yaml and vendored into charts/:
dependencies:
- name: dep
version: "0.1.0"
repository: "file://../dep" # or an https:// chart repo
condition: dep.enabled
helm dependency update app # resolves, vendors charts/dep-0.1.0.tgz, writes Chart.lock
helm dependency build app # re-vendors exactly what Chart.lock pins (the CI form)
A subchart reads its values under its own name (dep: here), condition switches the whole
subchart off, and global: is the only namespace both charts see.
Install, upgrade, roll back
Against a cluster (uses the current kubectl context and namespace):
helm install myrelease mychart # install a release
helm install myrelease mychart -f prod-values.yaml --set image.tag=1.4.2
helm upgrade --install myrelease mychart # create or update (idempotent)
helm upgrade --install myrelease mychart --rollback-on-failure --timeout 5m # Helm 3: --atomic
helm list -A # releases across all namespaces
helm history myrelease # revisions of a release
helm rollback myrelease 2 # roll back to revision 2
helm uninstall myrelease # remove the release
helm status myrelease # current state and notes
helm get values myrelease # values the release was installed with
helm get values myrelease -a # those merged with the chart defaults
helm upgrade --install is the usual CI/CD form: it installs on first run and upgrades afterwards.
--rollback-on-failure (--atomic in Helm 3, deprecated in Helm 4) implies --wait and rolls the
release back to the previous revision if the new one is not ready within --timeout (5 minutes by
default), which is what you want in a pipeline: a failed deploy leaves the cluster where it was
rather than half-migrated. On a first install, there is nothing to roll back to: the release is
uninstalled.
Validate values with a schema
A values.schema.json next to values.yaml is enforced on template, lint, install and upgrade:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["replicaCount"],
"properties": {
"replicaCount": { "type": "integer", "minimum": 1 }
}
}
$ helm template r mychart --set replicaCount=0
Error: values don't meet the specifications of the schema(s) in the following chart(s):
mychart:
- at '/replicaCount': minimum: got 0, want 1
Ten lines well spent: required guards one value at a time, the schema checks all of them before
anything renders.
Repositories
helm repo add <name> <url> # e.g. bitnami https://charts.bitnami.com/bitnami
helm repo update # refresh the local index
helm search repo <term> # find charts
helm show values <name>/<chart> # inspect before installing
helm install myrelease <name>/<chart> -f my-values.yaml
Artifact Hub indexes public charts.
Gotchas
All reproduced with helm v3.21.3.
toYaml only indents its first line. This template:
spec:
nodeSelector:
{{ toYaml .Values.nodeSelector }}
renders a: "1" at the right depth and b: "2" at column 0. Still valid YAML, no error, completely
different document. Pipe through nindent, which starts with a newline and indents every line:
spec:
nodeSelector:
{{- toYaml .Values.nodeSelector | nindent 4 }}
A missing value is silent, a missing parent is fatal. .Values.nope renders as an empty string;
.Values.a.b when a is absent stops the render with nil pointer evaluating interface {}.b. So
the well-behaved half of your values is the one that fails loudly. Guard what matters:
dsn: {{ required "dsn is required" .Values.dsn | quote }}
Neither template nor lint knows the Kubernetes schema. A Deployment carrying replicaz: 3
and a container field spelled imag: passes both with exit code 0, then the API server rejects it.
helm lint has a handful of Kubernetes-aware rules (a Deployment with no matchLabels does fail)
but no schema. The dry runs do not catch it either: helm upgrade --install --dry-run=server, and Helm 3’s
helm template --validate, both passed this chart against a live cluster. Only a real install
fails, so the check is an install into a throwaway namespace or cluster (kind is enough).
lookup returns an empty map offline. During helm template it never queries anything, so a
chart that branches on what already exists in the cluster renders one way in CI and another way on
install. Test those branches against a real cluster, not in the render loop.
A top-level --set does not reach a subchart. With a dep subchart reading .Values.mode,
--set mode=x sets the parent’s value and the subchart keeps its own default. Address it by name,
--set dep.mode=x, or move the value under global:.
helm upgrade does not merge with the previous values. With no -f and no --set at all, it
reuses the last release’s values; pass a single --set and every other previous value is dropped
for the chart’s defaults. Pass the same -f and --set on every upgrade, which is the right answer
anyway since they then live in git. --reuse-values and --reset-then-reuse-values exist for the
cases where you cannot.
Release names are capped at 53 characters and must match
^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$. Long branch names fed into a
release name from CI hit this. helm template checks it too, so render with the real name in CI
and it fails there, not at deploy time.
See also
- Official docs: https://helm.sh/docs/
- Kubernetes tooling for the wider kubectl toolbox.
- Kubernetes for the Kubernetes cookbook itself.