Skip to content

Helm

Helm is the standard package manager for Kubernetes. It wraps a set of manifests into a versioned, configurable artifact called a chart, and tracks deployed instances - called releases - so you can upgrade, diff, and roll back with a single command.

Without Helm, environment-specific configuration lives in duplicated YAML or fragile kustomize overlays. With Helm, you parameterize once and deploy anywhere.

How a release works

flowchart LR
    Chart["Chart\n(templates + defaults)"] --> Render["helm template\n(render with values)"]
    Values["values.yaml\n+ overrides"] --> Render
    Render --> Release["Release in cluster\n(tracked in Secrets)"]
    Release --> Upgrade["helm upgrade\n(diff + apply)"]
    Upgrade --> History["Release history\n(rollback target)"]

Helm stores each release's rendered manifest and metadata as a Secret in the target namespace. That history is what makes helm rollback work - it renders the previous revision's manifest and re-applies it.

Chart anatomy

my-chart/
├── Chart.yaml          # metadata: name, version, appVersion, dependencies
├── values.yaml         # default values for all templates
├── charts/             # vendored sub-charts (from helm dependency build)
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── _helpers.tpl    # named templates (partials)  -  not rendered directly
│   └── NOTES.txt       # post-install user-facing instructions
└── .helmignore

Chart.yaml fields that matter

apiVersion: v2
name: my-app
version: 1.4.2          # chart version  -  bump this on every chart change
appVersion: "2.0.1"     # application version  -  informational only
description: My application chart
type: application        # or "library" for reusable partials
dependencies:
  - name: postgresql
    version: "12.x.x"
    repository: "oci://registry-1.docker.io/bitnamicharts"
    condition: postgresql.enabled

Templating with Sprig

Helm templates use Go's text/template engine augmented with the Sprig function library.

# 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:
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          {{- with .Values.env }}
          env:
            {{- toYaml . | nindent 12 }}
          {{- end }}

Named templates (_helpers.tpl)

{{/*
Expand the name of the chart.
*/}}
{{- define "my-app.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels applied to every resource.
*/}}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

Prefer include over template - include returns a string you can pipe through nindent, quote, and other functions.

Values architecture

A production values strategy has three layers:

values.yaml           ← chart defaults (committed, no secrets)
values-prod.yaml      ← environment overrides (committed)
values-secrets.yaml   ← sensitive overrides (from Vault/SOPS, not committed)
helm upgrade my-app ./my-chart \
  -f values-prod.yaml \
  -f values-secrets.yaml \
  --set image.tag=v2.3.1

Values from rightmost -f file win. --set wins over all files.

Secrets in values

Never commit plaintext secrets. Common patterns:

  • External Secrets Operator: Helm creates the ExternalSecret CRD, which pulls from Vault/SSM at runtime.
  • SOPS + helm-secrets plugin: helm secrets upgrade ... decrypts on the fly.
  • Sealed Secrets: encrypt with the cluster's public key; commit the SealedSecret manifest.

Install and upgrade workflow

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm install my-app ./my-chart -n platform --create-namespace -f values-prod.yaml

# Before upgrading: render and diff
helm diff upgrade my-app ./my-chart -n platform -f values-prod.yaml
helm upgrade my-app ./my-chart -n platform -f values-prod.yaml --atomic --cleanup-on-fail

# Verify, then rollback if needed
helm status my-app -n platform
helm history my-app -n platform
helm rollback my-app 3 -n platform

--atomic rolls back automatically if the upgrade fails. --cleanup-on-fail deletes newly created resources on failure.

Hooks

Hooks are Jobs (or Pods) that run at defined release lifecycle points.

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "-5"          # lower runs first
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["python", "manage.py", "migrate"]
Hook Use case
pre-install / pre-upgrade database migrations, CRD registration
post-install / post-upgrade smoke tests, cache warm-up
pre-delete drain connections, archive data
test helm test triggered checks

hook-delete-policy: before-hook-creation prevents old hook Jobs from blocking subsequent upgrades. hook-succeeded cleans up passing Jobs automatically.

Helm test

Ship test Jobs inside your chart to verify a deployed release:

# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "my-app.fullname" . }}-test-connection"
  annotations:
    "helm.sh/hook": test
spec:
  restartPolicy: Never
  containers:
    - name: wget
      image: busybox
      command: ['wget', '--spider', 'http://{{ include "my-app.fullname" . }}:{{ .Values.service.port }}/health']
helm test my-app -n platform --logs

OCI registry support

Charts can be stored in OCI registries (Docker Hub, ECR, Artifact Registry, Harbor):

helm package ./my-chart
helm push my-chart-1.4.2.tgz oci://registry.example.com/charts

helm pull oci://registry.example.com/charts/my-chart --version 1.4.2
helm install my-app oci://registry.example.com/charts/my-chart --version 1.4.2

OCI registries don't need helm repo add. Use your existing container registry for chart storage and your existing registry credentials.

Library charts

A library chart (type: library in Chart.yaml) exports only named templates - no manifests rendered directly. Use it to share _helpers.tpl patterns across charts in a monorepo.

# Chart.yaml for an application chart
dependencies:
  - name: my-lib
    version: "0.1.0"
    repository: "oci://registry.example.com/charts"
# In application template
{{- include "my-lib.deployment" . }}

Dependency management

helm dependency update ./my-chart   # resolve and download to charts/
helm dependency build ./my-chart    # rebuild from Chart.lock (CI usage)

Pin dependencies to exact versions or SemVer ranges in Chart.yaml. Use condition: to make sub-charts opt-in via values.

Debugging rendered output

helm template my-app ./my-chart -f values-prod.yaml        # render locally
helm template my-app ./my-chart -f values-prod.yaml --debug # show computed values
helm get manifest my-app -n platform                        # what's live in cluster
helm get values my-app -n platform                          # what values are active

Use the helm-diff plugin in CI to produce a diff between the current release and what helm upgrade would apply. This is the best way to catch unintended template changes before deployment.

Common production mistakes

Mistake Fix
Bumping appVersion without bumping version Always increment version when chart content changes
--set in CI without a diff step Use helm diff to catch unexpected changes
Forgetting --atomic on upgrade Cluster can end up with partial upgrade if pods fail
Using latest as the image tag Use explicit digest or immutable tag; appVersion should match
Storing release secrets in default namespace Deploy to the namespace you intend; secrets stay there