Helm

yamlrelease

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

Related

Keyboard shortcuts

/Open search
CtrlKOpen search (Cmd+K on macOS)
?Show this cheat sheet
Ctrl/Show this cheat sheet (Cmd+/ on macOS)
EscClose search or this cheat sheet

Turns off the single-key shortcuts (/, ?). Ctrl/Cmd+K and Ctrl/Cmd+/ stay.