Install
# Linux
curl https://baltocdn.com/helm/signing.asc | sudo gpg --dearmor -o /usr/share/keyrings/helm.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/helm.gpg] \
https://baltocdn.com/helm/stable/debian/ all main" \
| sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt update
sudo apt install helm
# macOS
brew install helm
# Or via mise (see /tutorials/mise-polyglot-runtime-versions.html)
mise use -g helm@latest
helm version
Install someone else's chart
# Add a chart repository
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
# Search what's available
helm search repo ingress-nginx
# Install (release-name + chart-name)
helm install nginx-ingress ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace \
--version 4.11.3
# See what got installed
helm list -A
kubectl get all -n ingress-nginx
"Release" is Helm's term for an installation. The same chart can be installed multiple times under different release names; each has independent state in the cluster.
Configure via values overrides
Every chart ships a values.yaml with defaults. Override what you need:
# my-values.yaml
controller:
replicaCount: 3
service:
type: LoadBalancer
loadBalancerIP: "192.168.1.220" # paired with MetalLB; see /tutorials/metallb-bare-metal-loadbalancer.html
config:
use-forwarded-headers: "true"
proxy-body-size: "100m"
metrics:
enabled: true
serviceMonitor:
enabled: true
helm install nginx-ingress ingress-nginx/ingress-nginx \
-n ingress-nginx --create-namespace \
-f my-values.yaml
# Or inline overrides
helm install nginx-ingress ingress-nginx/ingress-nginx \
--set controller.replicaCount=3 \
--set controller.service.type=LoadBalancer
# To see effective values
helm get values nginx-ingress -n ingress-nginx
Upgrade
# Upgrade to a new chart version (re-using existing values)
helm upgrade nginx-ingress ingress-nginx/ingress-nginx \
-n ingress-nginx \
--version 4.12.0 \
--reuse-values
# Or with new values
helm upgrade nginx-ingress ingress-nginx/ingress-nginx \
-n ingress-nginx -f new-values.yaml
# Rollback if something breaks
helm history nginx-ingress -n ingress-nginx
helm rollback nginx-ingress 3 -n ingress-nginx
Helm keeps the full release history (default: last 10) so rollbacks are one command. The history is stored in Kubernetes Secrets in the release's namespace.
Write your own chart
helm create my-app
# Generates a scaffold:
# my-app/
# Chart.yaml — chart metadata
# values.yaml — default values
# templates/
# deployment.yaml
# service.yaml
# ingress.yaml
# _helpers.tpl — reusable template snippets
# charts/ — subchart dependencies
# README.md
Templates use Go templating with sprig functions:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-app.fullname" . }}
labels:
{{- include "my-app.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "my-app.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "my-app.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
ports:
- containerPort: {{ .Values.service.targetPort }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
Test rendering before applying:
helm template my-app ./my-app -f my-app/values.yaml
# Prints fully-rendered YAML to stdout; review by eye.
helm install --dry-run --debug my-app ./my-app
# Same idea but also validates against the cluster API.
helm lint ./my-app
# Schema + best-practice checks.
The Chart.yaml
apiVersion: v2
name: my-app
description: A Helm chart for my-app
type: application
version: 1.2.0 # chart version (semver) — bumped on chart changes
appVersion: "0.5.3" # app version — bumped on application changes
keywords: [web, api]
home: https://example.com
sources:
- https://github.com/me/my-app
dependencies:
- name: postgresql
version: "16.x"
repository: oci://registry-1.docker.io/bitnamicharts
condition: postgresql.enabled
alias: db
Dependencies are subcharts. helm dependency update downloads them into charts/. The optional condition means the subchart only installs if the parent's postgresql.enabled value is true — cleanly enables or disables a database alongside your app.
OCI distribution: charts as container artifacts
Modern Helm uses OCI registries (Docker Hub, GitHub Container Registry, AWS ECR, etc.) for chart distribution — same registries you push container images to:
# Package the chart
helm package ./my-app
# Push to an OCI registry
helm push my-app-1.2.0.tgz oci://ghcr.io/myorg/charts
# Install from OCI
helm install my-app oci://ghcr.io/myorg/charts/my-app --version 1.2.0
Replaces the older helm repo add + index.yaml pattern; the same registry can host both container images and charts.
Hooks
Annotations on templates make them run at specific lifecycle points:
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "1"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: my-app:{{ .Values.image.tag }}
command: ["./migrate", "up"]
The Job runs before each install / upgrade; rollback waits for it to succeed before proceeding. Useful for database migrations, cert generation, pre-flight checks.
Helm vs Kustomize vs Plain manifests
- Helm — templating + dependencies + lifecycle + community-chart ecosystem. Best for "I want to install third-party software" and "I'm distributing my app for others to install."
- Kustomize — overlay-based; no templating, just patches on top of base manifests. Cleaner for "I have a small number of envs with a few differences each." Built into kubectl as
kubectl -k. - Plain YAML in Git + Argo CD (see that tutorial) — for apps you fully control and don't need to parameterize heavily, simplest path.
The good answer is often "both": Helm to install third-party software (cert-manager, ingress-nginx, prometheus-stack), Kustomize / Argo CD to manage your own apps. Argo CD handles either natively.
Helmfile: orchestrating many releases
For "install these 12 charts with these overrides as a unit," helmfile is a declarative wrapper:
# helmfile.yaml
releases:
- name: cert-manager
namespace: cert-manager
chart: jetstack/cert-manager
version: "1.15.0"
values: [ values/cert-manager.yaml ]
set:
- name: installCRDs
value: true
- name: nginx-ingress
namespace: ingress-nginx
chart: ingress-nginx/ingress-nginx
version: "4.11.3"
values: [ values/nginx.yaml ]
needs: [ cert-manager/cert-manager ] # ordering
helmfile sync # install / upgrade all
helmfile diff # show what would change
helmfile apply # diff + sync if changes
The gotchas
- Quoting in templates.
{{ .Values.foo | quote }}often matters when YAML strings could be parsed as booleans / numbers ("true"vstrue). - nindent vs indent.
{{- something | nindent 4 }}prepends a newline and indents;{{- something | indent 4 }}doesn't. The first is almost always what you want inside a YAML block. - --reuse-values vs --reset-values. On upgrade,
--reuse-valuescarries forward the previous override set;--reset-valuesstarts from chart defaults. Surprise CSI driver re-installs come from this. - Helm 2 → 3 was a big break. Helm 3 removed Tiller; old charts may need updates. Don't use Helm 2 in 2026.