Helm
What Helm is
Helm is a package manager for Kubernetes. A “chart” is a parameterized bundle of Kubernetes manifests. You install a chart with a set of values; Helm renders the templates into concrete YAML and applies it to the cluster. You get:
- Reusable, versioned packages.
- A single command to deploy complex apps (
helm install). - Upgrade and rollback semantics with revision history.
- Dependency composition (charts that depend on other charts).
CNCF graduated project. On v3.x since 2019 (v3 dropped Tiller, the server-side component of v2 that caused every security problem Helm ever had). Pair with the Kubernetes topic for what Helm is installing onto.
Anatomy of a chart
mychart/├── Chart.yaml # metadata: name, version, dependencies├── values.yaml # default values├── values.schema.json # (optional) JSON Schema for values├── charts/ # subcharts / dependencies (vendored or downloaded)├── templates/ # Go templates rendered into manifests│ ├── _helpers.tpl # partial templates / helper functions│ ├── deployment.yaml│ ├── service.yaml│ ├── configmap.yaml│ ├── ingress.yaml│ ├── NOTES.txt # shown after install/upgrade│ └── tests/ # helm test targets├── crds/ # CRDs installed before any templates└── README.mdChart.yaml
apiVersion: v2name: home-health-apidescription: Django API for the home-health platformtype: applicationversion: 1.2.3 # chart version (semver)appVersion: "2.0.1" # app version, informational
dependencies: - name: postgresql version: "12.x.x" repository: https://charts.bitnami.com/bitnami condition: postgresql.enabledversion is the chart version (bumped when you change templates). appVersion is the thing being deployed (your app’s git tag). They move independently.
values.yaml
replicaCount: 3image: repository: acme/home-health-api tag: "" # defaults to .Chart.AppVersion if empty pullPolicy: IfNotPresent
service: type: ClusterIP port: 80 targetPort: 8000
resources: requests: cpu: 100m memory: 256Mi limits: memory: 512Mi
ingress: enabled: false className: nginx hosts: - host: api.example.com paths: - path: / pathType: Prefix
postgresql: enabled: true auth: database: home_healthValues are the contract between the chart author and the chart user. Name them carefully, changing a values key is a breaking change.
templates/
Go templates with Sprig function extensions. Each .yaml in here renders into a manifest at install time:
apiVersion: apps/v1kind: Deploymentmetadata: name: {{ include "mychart.fullname" . }} labels: {{- include "mychart.labels" . | nindent 4 }}spec: replicas: {{ .Values.replicaCount }} selector: matchLabels: {{- include "mychart.selectorLabels" . | nindent 6 }} template: metadata: 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 }} resources: {{- toYaml .Values.resources | nindent 12 }}The template language is the single biggest learning curve in Helm. It’s Go templates, which are ugly compared to Jinja2 or Liquid. Live with it.
_helpers.tpl
Partial templates, named by define:
{{- define "mychart.fullname" -}}{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}{{- end }}
{{- define "mychart.labels" -}}helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}app.kubernetes.io/name: {{ .Chart.Name }}app.kubernetes.io/instance: {{ .Release.Name }}app.kubernetes.io/managed-by: {{ .Release.Service }}{{- if .Chart.AppVersion }}app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}{{- end }}{{- end }}Call them from .yaml templates with {{ include "mychart.labels" . }}. Keep naming and labeling logic here; don’t repeat it in every manifest.
The release lifecycle
helm install → create a new releasehelm upgrade → apply changes to an existing releasehelm rollback → revert to a previous revisionhelm uninstall → delete the release and all its resourcesEach install or upgrade creates a revision. Helm stores the rendered manifest and values for every revision in a Secret (by default) in the release’s namespace. This is what makes rollback instant, no re-render, no re-fetch.
helm install home-health ./mychart --namespace home-health --create-namespacehelm upgrade home-health ./mychart --set image.tag=v2.1.0helm rollback home-health 3 # back to revision 3helm history home-health # all revisions with timestampshelm uninstall home-healthValues layering, the multi-env workflow
For a service deployed to dev / staging / prod, don’t copy-paste. Layer values files:
deploy/home-health/├── values.yaml # shared defaults├── values-dev.yaml # dev overrides├── values-staging.yaml # staging overrides└── values-prod.yaml # prod overrideshelm upgrade --install home-health ./chart \ -f deploy/home-health/values.yaml \ -f deploy/home-health/values-prod.yaml \ --namespace home-healthPrecedence (later wins):
values.yamlinside the chart itself.- Each
-f values-file.yamlin the order given. --set key=valueon the command line.--set-file key=@pathfor loading a file as a value.
Lint the composition:
helm template home-health ./chart -f values.yaml -f values-prod.yaml > rendered.yaml# inspect rendered.yaml; verify the output is what you expecthelm template is a powerful debugging tool. Render locally, diff, commit the diff in your PR. You catch misconfigurations before they become events in a cluster.
Dependencies and subcharts
Your chart can depend on other charts:
dependencies: - name: postgresql version: "12.x.x" repository: https://charts.bitnami.com/bitnami condition: postgresql.enabled alias: dbhelm dependency update # fetches dependencies into charts/helm dependency build # builds from Chart.lockDependency values live under a top-level key named after the dependency (or alias):
postgresql: enabled: true auth: database: home_healthUmbrella chart, your chart’s only job is to wire together dependencies. templates/ is nearly empty.
Library chart, a chart of only helpers (no resources). type: library in Chart.yaml. Other charts depend on it to share _helpers.tpl-style partials. Useful when you have 10 service charts that all need the same labels, probes, and image-pull config.
The ConfigMap hash pattern, how to roll Pods on config changes
A ConfigMap change alone does not roll Deployments. Pods continue using the old config until they restart. Common Helm idiom:
apiVersion: apps/v1kind: Deployment# ...spec: template: metadata: annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}When the ConfigMap renders differently, the sha changes, the Pod template hash changes, Kubernetes rolls the Deployment. No external controller required.
Same pattern for Secrets (though be aware this puts secret contents through sha256sum; for sensitive values, use a version string instead).
Hooks
Lifecycle hooks let you run Kubernetes resources at specific points:
metadata: annotations: "helm.sh/hook": pre-install,pre-upgrade "helm.sh/hook-weight": "-5" "helm.sh/hook-delete-policy": hook-succeededHook types:
pre-install,post-installpre-upgrade,post-upgradepre-delete,post-deletepre-rollback,post-rollbacktest,helm test <release>runs only these
Use for database migrations, one-time schema jobs, validation runs. Hooks run outside the normal sync so they don’t show up in helm list resources.
Tests
A templates/tests/*.yaml with the helm.sh/hook: test annotation:
apiVersion: v1kind: Podmetadata: name: "{{ .Release.Name }}-connection-test" annotations: "helm.sh/hook": testspec: containers: - name: test image: curlimages/curl command: ["curl", "-f", "http://{{ include "mychart.fullname" . }}:{{ .Values.service.port }}/healthz"] restartPolicy: NeverRun after install: helm test home-health. Good for smoke tests; won’t catch much beyond that.
Repositories
Traditional HTTP repository:
helm repo add bitnami https://charts.bitnami.com/bitnamihelm repo updatehelm install my-pg bitnami/postgresqlOCI registries (the modern default):
helm push mychart-1.2.3.tgz oci://ghcr.io/acme/chartshelm pull oci://ghcr.io/acme/charts/mychart --version 1.2.3helm install my-app oci://ghcr.io/acme/charts/mychart --version 1.2.3OCI charts live alongside container images in the same registry, one credential, one auth story. Since Helm 3.8 (2022), this is the recommended distribution method.
Alternatives and when to pick them
| Tool | Strengths | When to pick |
|---|---|---|
| Helm | Mature, huge chart ecosystem, dependency composition, revisions | Distributing software; complex configs with many knobs |
| Kustomize | No templates, patches; built into kubectl | Smallish in-house apps; base + overlay pattern |
| Jsonnet / Tanka | Real programming language | Heavy config generation; shared libraries across teams |
| Timoni | CUE-based, type-checked | Schema-first workflows; teams that want guardrails |
| Pulumi / CDK8s | Write manifests in TypeScript/Go/Python | You already write IaC in a general-purpose language |
| Plain YAML | Zero complexity | Tiny deployments where templating isn’t earning its keep |
Most production stacks pick Helm for third-party apps and Kustomize for their own. Both can be applied by ArgoCD.
Common footguns
- Template whitespace. Go templates render literal newlines; YAML is sensitive. Use
{{- ... -}}andnindentreligiously, or your output has blank lines or trailing garbage. - The
.reassignment. Inside arange,.changes. Save the outer scope at the top ({{ $ := . }}) or use$directly. - Secrets in values.
helm install --set password=...persists in the release Secret. Not ideal. Use external secret management. helm installvshelm upgrade --install. The latter is idempotent; use it in CI. The former fails if the release already exists.- Breaking values changes without a
versionbump. Consumers upgrade and suddenly their values don’t work. Bumpversion(the chart version) on every breaking change; document migration. - Missing the
values.schema.json. Users pass wrong types, Helm renders nonsense, and you find out atkubectl applytime. Schemas catch misuse athelm template. - Overly clever helper functions. Helm template debugging is hard.
helm template --debugis your friend. Keep helpers simple and well-tested. - Subcharts that override each other’s values. Two dependencies each define a key named
service, whichever loads last wins. Use aliases (alias: db) to namespace. helm upgradeon a chart with schema changes to CRDs. Helm won’t update CRDs fromcrds/after initial install. Manually apply CRD updates before upgrading.- Rollback of a deletion.
helm rollbackcan’t recover a resource the chart no longer defines. Roll forward to a fixed version.
Debugging
helm template <release> <chart> -f values.yaml --debug # render and showhelm install <release> <chart> --dry-run --debug # render + validatehelm lint <chart> # syntactic + schema checkshelm get values <release> # values used on current revisionhelm get manifest <release> # final rendered YAMLhelm history <release> # revision historyhelm get manifest | kubectl diff -f - shows the real diff between cluster and release.
Operational patterns worth stealing
- One values file per environment, layered on top of shared defaults. The foundation of sane multi-env.
- Library charts for labels, probes, image-pull config. Write the boilerplate once.
- CRDs in
crds/, nottemplates/. They install before templates and aren’t touched by upgrades. values.schema.jsonon every chart you publish. Schema is documentation that tools enforce.- Commit
Chart.lock. Pin subchart versions for reproducibility. helm templatein CI. Render and diff on every PR so reviewers see the resulting manifests.helm-secretsor equivalent for encrypted values-file sections, so you can commit production configuration without leaking secrets.
References
- Helm documentation
- Chart best practices
- Template function list (Go + Sprig)
- Artifact Hub, the canonical public chart registry
- Bitnami charts, large collection of production-ready dependency charts
- helm-secrets, SOPS-backed encrypted values
- Timoni, the CUE-based alternative
- Kustomize, the template-free alternative
Related topics
- Kubernetes, the substrate Helm deploys to
- ArgoCD, applies Helm charts in a GitOps loop
- GitOps, the deployment philosophy Helm plugs into
- Terraform, provisions the cluster Helm runs on