Skip to main content

Helm basics

Kubernetes manifests are static YAML. The moment you need the same application in staging and production with different replica counts, image tags, and hostnames, you need either a templating layer or a lot of copy-paste. Helm is the most widely deployed answer.

The four nouns

TermWhat it is
ChartA versioned package of templates plus default values
ValuesThe configuration input, merged from values.yaml, -f files, and --set flags
ReleaseOne installation of a chart into a cluster, with a name and a revision history
RepositoryAn HTTP server (or OCI registry) hosting packaged charts

The key mental model: Helm renders templates locally into plain Kubernetes manifests, then sends those manifests to the API server. There is no in-cluster Helm component. Helm 3 removed Tiller entirely; release state is stored in a Secret in the release's namespace.

# Prove it — render without touching the cluster
helm template myrelease ./mychart --values prod-values.yaml

Chart layout

mychart/
├── Chart.yaml # name, version, appVersion, dependencies
├── values.yaml # default configuration
├── values.schema.json # optional JSON Schema, validated on install
├── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── _helpers.tpl # named template definitions; not rendered itself
│ └── NOTES.txt # printed after install
├── charts/ # vendored subchart dependencies
└── .helmignore

Two versions live in Chart.yaml and they mean different things:

Chart.yaml
apiVersion: v2
name: mychart
description: A web service
type: application
version: 1.4.2 # the CHART version — bump on any chart change
appVersion: '2.7.0' # the APPLICATION version — the image tag you ship

Templating

Templates are Go templates with the Sprig function library plus a handful of Helm-specific additions.

templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "mychart.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
# Roll the Pods whenever the ConfigMap content changes
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
labels:
{{- include "mychart.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}

The built-in objects

ObjectContains
.ValuesThe merged values
.ChartFields from Chart.yaml.Chart.Name, .Chart.Version, .Chart.AppVersion
.Release.Release.Name, .Release.Namespace, .Release.IsUpgrade, .Release.Revision
.CapabilitiesCluster facts — .Capabilities.KubeVersion, .Capabilities.APIVersions.Has
.FilesNon-template files in the chart, via .Files.Get

Whitespace, the part everyone fights

  • {{- trims whitespace before the action, including the preceding newline.
  • -}} trims whitespace after it.
  • nindent N prepends a newline and indents every line by N spaces — the correct tool for splicing a YAML block into a nested position.
  • indent N indents without the leading newline.

Named templates

_helpers.tpl holds reusable fragments. The leading underscore tells Helm not to treat the file as a manifest.

{{/* templates/_helpers.tpl */}}
{{- define "mychart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "mychart.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name (include "mychart.name" .) | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}

{{- define "mychart.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{- define "mychart.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{ include "mychart.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

The trunc 63 is not decoration — Kubernetes label values and most name fields are limited to 63 characters, and a long release name will otherwise produce an object the API server rejects.

Values precedence

Later sources win:

  1. The chart's own values.yaml
  2. A parent chart's values (for subcharts)
  3. -f / --values files, in the order given
  4. --set, --set-string, --set-file, --set-json
helm upgrade --install web ./mychart \
-f values.yaml \
-f values-prod.yaml \
--set image.tag=2.7.1 \
--set-string podAnnotations.buildId=00421

Everyday commands

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm search repo bitnami/postgresql --versions

# upgrade --install is idempotent: use it for both first install and updates
helm upgrade --install pg bitnami/postgresql \
--namespace data --create-namespace \
--version 16.2.1 \
--set auth.database=appdb \
--wait --timeout 5m

--atomic is the flag worth remembering: it rolls the release back automatically if the upgrade fails, so you never sit in a half-applied state. It implies --wait.

Dependencies

Chart.yaml
dependencies:
- name: postgresql
version: '16.2.1'
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled # skip the subchart when false
alias: primary # rename it in values
helm dependency update ./mychart # writes Chart.lock and populates charts/
helm dependency build ./mychart # installs exactly what Chart.lock pins

Commit Chart.lock. Treat vendored charts/*.tgz the way you treat node_modules — usually ignored, and rebuilt from the lock file in CI.

Hooks

Annotations turn a manifest into a lifecycle hook, run at a specific point rather than as part of the release:

metadata:
annotations:
'helm.sh/hook': pre-upgrade
'helm.sh/hook-weight': '-5' # lower runs first
'helm.sh/hook-delete-policy': hook-succeeded

Available points include pre-install, post-install, pre-upgrade, post-upgrade, pre-delete, post-delete, and test. Database migrations are the canonical pre-upgrade Job.

When Helm is not the answer

Helm's templating is string manipulation over YAML, which becomes hard to reason about in large charts. Alternatives worth knowing:

  • Kustomize — overlays and strategic merge patches, no templating language, built into kubectl apply -k. Excellent for "same manifests, small per-environment differences".
  • cdk8s / Pulumi — define manifests in a real programming language with real types.
  • Timoni / KCL — CUE-based, schema-first configuration with strong validation.

Helm remains the default for distributing third-party applications, because a versioned, packaged artifact with a values contract is exactly what that job needs.