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" vs true).
  • 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-values carries forward the previous override set; --reset-values starts 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.