Helm
The Package Manager for Kubernetes — Templated YAML, Versioned Releases
Helm packages Kubernetes manifests as charts: a directory of templated YAML
plus a values.yaml of defaults. helm install myapp ./mychart renders
the templates with your values, sends the result to the API server, and records
the release state in a Secret. helm upgrade, helm rollback,
and helm uninstall all work against that release state.
Helm 3 (2019) eliminated the cluster-side Tiller component that made Helm 2 a security liability. Today Helm is a pure client-side tool that uses Kubernetes' own RBAC — no special privileges, no hidden control plane. Charts can live in an HTTP chart repo (the classic) or an OCI registry alongside your container images (the modern way).
What helm install Does
Key Numbers
1. Helm Architecture
Helm is a client-side tool. There is no server-side component in Helm 3 — the CLI directly calls the Kubernetes API. Understanding the architecture explains why Helm behaves the way it does: why release state survives restarts, how upgrades work, and what the Tiller removal really changed.
The Helm Client (CLI)
The helm binary is a Go program that implements the Helm client.
It reads chart files, renders templates, and communicates with the Kubernetes
API server over HTTPS. No in-cluster agent is required.
# The helm binary location and basic commands
$ which helm
/usr/local/bin/helm
$ helm version
v3.15.4
# version output shows:
# version.BuildInfo{Version:"v3.15.4", GitCommit:"...", GitTreeState:"...", GoVersion:"go1.22"}
# Environment variables that affect Helm behavior
HELM_CACHE_HOME # where Helm caches things (default ~/.cache/helm)
HELM_CONFIG_HOME # where Helm stores config (default ~/.config/helm)
HELM_DATA_HOME # where Helm stores data (default ~/.local/share/helm)
HELM_DRIVER # secret | configmap | sql (default secret in 3.4+)
KUBECONFIG # standard kubectl kubeconfig lookup
HELM_KUBECONTEXT # explicit kubeconfig context name
HELM_NO_PLUGINS # disable plugins (set to 1)
HELM_REGISTRY_CONFIG # path to registry config (default ~/.config/helm/registryconfig.json)
HELM_REPOSITORY_CONFIG # path to repo index (default ~/.config/helm/repositories.yaml)
HELM_REPOSITORY_CACHE # path to repo cache files (default ~/.cache/helm/repository) In-Cluster State: How Helm 3 Stores Release Data
Helm 3 persists every release as a Kubernetes Secret (by default) in the
target namespace. The Secret name follows the pattern
sh.helm.release.v1.<release-name>.<revision>. Each
helm install or helm upgrade creates a new Secret
revision — Helm never mutates an existing Secret. This gives you free
revision history without any external database.
# Inspect the Secrets Helm creates for a release
$ kubectl get secrets -n mynamespace | grep sh.helm.release
NAME TYPE DATA AGE
sh.helm.release.v1.myapp.v1 helm.sh/release.v1 1 10d
sh.helm.release.v1.myapp.v2 helm.sh/release.v1 1 5d
sh.helm.release.v1.myapp.v3 helm.sh/release.v1 1 2h
# Decode a release Secret to see what's stored
$ kubectl get secret sh.helm.release.v1.myapp.v3 -n mynamespace \
-o jsonpath='{.data.release}' | base64 -d | base64 -d | gzip -d | python3 -m json.tool
# Sample output structure:
{
"name": "myapp",
"info": {
"first_deployed": "2024-01-15T10:30:00Z",
"last_deployed": "2024-01-20T14:22:00Z",
"deleted": "",
"status": "deployed",
"notes": "..."
},
"chart": {
"metadata": {"name": "mychart", "version": "1.2.3", "appVersion": "4.5.6"},
"templates": [...],
"values": {...}
},
"config": {...},
"version": 3
}
# Switch storage driver to ConfigMap (not recommended for production)
HELM_DRIVER=configmap helm install myapp ./mychart -n mynamespace
# Switch to SQL (PostgreSQL) for large-scale multi-tenant deployments
HELM_DRIVER=sql \
HELM_SQL_CONNECTION_STRING="postgres://user:pass@localhost:5432/helm?sslmode=disable" \
helm install myapp ./mychart Tiller History and Why It Was Removed
Helm 2 shipped with a cluster-side component called Tiller (the server-side part
of Helm). Tiller ran as a Deployment in the kube-system namespace with
broad permissions, and it was responsible for applying charts and tracking release
state inside the cluster. This architecture had several serious problems:
- Overprivileged by default: Tiller needed cluster-admin to manage releases in any namespace, meaning any compromise of the Tiller API was a full cluster compromise.
- No namespace isolation: Tiller operated cluster-wide. There was no way to restrict a team to a single namespace.
- Release state collision: Two teams deploying the same release name in different namespaces could collide because Tiller had no concept of namespaced release identity.
- Requires 'helm init': You had to run
helm initto install Tiller before using the CLI — a step that felt awkward and added friction. - gRPC complexity: Helm 2 used gRPC for client-to-Tiller communication, which complicated load balancing and networking in multi-cluster setups.
Helm 3's design sidesteps all of these by making the Kubernetes API server the
source of truth. Release state lives in the same namespace as the release, which
means normal RBAC policies govern who can install, upgrade, or delete charts.
Namespaced identity is natural — helm list -n myns shows only releases
in that namespace.
The 3-Way Strategic Merge Patch
When you run helm upgrade, Helm performs a 3-way diff between:
- Previous release manifest: What Helm last deployed (from the release Secret)
- Live cluster state: What is actually running in the cluster right now
- New desired manifest: What the chart specifies now
This is different from kubectl apply, which uses the last-applied-configuration
annotation on each object. Helm doesn't use that mechanism — it has its own
revision history in Secrets. The practical implication: if someone manually edits
a resource that was deployed by Helm, Helm will try to preserve those manual changes
during the next upgrade unless they conflict with the new chart's intent.
This is generally a feature, not a bug, but it means Helm is not idempotent in the
same way kubectl apply is.
# Upgrade scenario: manual edit detected
# Previous release: replicas=3
# Live cluster: replicas=5 (someone ran kubectl scale)
# New chart: replicas=3
$ helm upgrade myapp ./mychart -n mynamespace
# Helm sees the discrepancy. Default behavior: apply chart's desired state.
# The manual scale is treated as a temporary change and gets overwritten.
# Result: replicas=3
# If you want Helm to NOT override manual changes (use with caution):
# There is no built-in flag for this. The proper approach is to represent
# the editable field as a value that comes from values.yaml, not hardcoded
# in the chart. Then the user's manual override lives in values, not in
# the live object. kubectl scale and then running helm upgrade will revert
to the chart's value. For fields that users should be allowed to override manually,
design your chart to accept them as values rather than hardcoding them.
2. Chart Structure Deep Dive
A Helm chart is a directory tree. Understanding what each file does and when it is processed is essential for writing reliable charts.
Chart.yaml
The Chart.yaml is the chart's metadata. It declares the chart's name,
version, type, and dependencies. Two API versions exist: v1 for legacy
charts (Helm 2 compatible) and v2 for Helm 3 charts. Always use v2
for new charts.
# Chart.yaml — minimal
apiVersion: v2
name: mychart
version: 1.0.0
appVersion: "1.2.3"
# Chart.yaml — full
apiVersion: v2
name: mychart
description: A robust web application chart with HA support
type: application # application | library
version: 1.2.3
appVersion: "4.5.6"
kubeVersion: ">=1.24.0" # semver range for K8s version compatibility
dependencies: # chart dependencies (see Section 5)
- name: postgresql
version: "14.x.x"
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
tags:
- database
- storage
import-values: [] # mapping for subchart values
alias: my-postgres # alias for this dep in parent values
keywords:
- web
- http
- api
home: https://github.com/myorg/mychart
sources:
- https://github.com/myorg/mychart
maintainers:
- name: Platform Team
email: [email protected]
url: https://myorg.slack.com/channels/platform
icon: https://myorg.com/charts/mychart/icon.svg
deprecated: false # marks chart as deprecated
annotations:
category: web # free-form annotations for tooling values.yaml and Values Hierarchy
The values.yaml provides defaults for all configurable parameters.
Values are merged in a specific priority order (lowest to highest priority):
- Chart's built-in defaults (
values.yaml) - Parent chart's values (if this chart is a subchart)
- A values file (
-f values.prod.yaml) — applied in the order listed --setflags (in the order listed)--set-file(read file contents as value)--set-string(force string type)
# values.yaml — comprehensive structure
# Top-level sections typically map to major chart components
replicaCount: 2 # how many replicas to deploy
image: # image configuration
repository: nginx
tag: "" # empty → use .Chart.AppVersion
pullPolicy: IfNotPresent
pullSecrets: [] # for private registries
service: # Service configuration
enabled: true
type: ClusterIP # ClusterIP | NodePort | LoadBalancer
port: 80
annotations: {} # cloud provider annotations
labels: {}
ingress: # Ingress configuration
enabled: true
className: nginx # ingress class name
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
hosts:
- host: myapp.mycompany.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: myapp-tls
hosts:
- myapp.mycompany.com
resources: # Resource limits/requests
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
autoscaling: # HPA configuration
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 75
targetMemoryUtilizationPercentage: 80
env: # Environment variables (flat map)
# Results in: env: [{name: "MODE", value: "production"}, ...]
MODE: production
LOG_LEVEL: info
DB_HOST: postgres
envFrom: [] # ConfigMap/Secret as env source
# - configMapRef:
# name: my-config
# - secretRef:
# name: my-secret
secret: # Inline secret values
# These get created as a Kubernetes Secret, not printed in the chart
DATABASE_URL: "postgres://user:pass@host/db"
API_KEY: "secret-key-123"
persistence: # PVC configuration
enabled: true
storageClass: "standard"
size: 10Gi
accessMode: ReadWriteOnce
annotations: {}
nodeSelector: {} # node selector labels
tolerations: [] # pod tolerations
affinity: {} # pod affinity/anti-affinity
# Section for conditional inclusion of components
components:
monitoring:
enabled: true
serviceMonitor:
enabled: true
redis:
enabled: false
# Tags for conditional enablement of groups of dependencies
tags:
database: true
monitoring: true templates/ Directory
The templates/ directory contains Go templates that Helm renders
into Kubernetes manifests. Files are processed in alphabetical order. Two special
files are processed before all others:
_helpers.tpl— defines named templates (functions) used by other templates_.tpl— same as _helpers.tpl (both conventions exist)
Everything else in templates/ is expected to produce a valid Kubernetes
manifest. Files named with a _ prefix (other than _helpers.tpl)
are not rendered as manifests — they're included as partials.
charts/ Directory (Subcharts)
Charts can include other charts as subcharts in the charts/ directory.
This is one way to bundle dependencies. The other way is via the dependencies:
block in Chart.yaml, which fetches charts from repositories at build time. The
charts/ directory approach is useful when you want to ship the subchart
alongside the parent, but it doesn't support version constraints — you put the
tarball there directly.
crds/ Directory
CRDs placed in the crds/ directory are installed before any template
rendering occurs, and they are installed as plain YAML (not templated). This is
the only supported way for a chart to add CRDs. Helm will refuse to install the
chart if the CRD already exists and is different from what's in the chart (Helm
will show an error with the conflict).
crds/ must be valid Kubernetes CRD YAML
and must NOT use Go template syntax. Helm installs them via raw HTTP PUT to the
API server, bypassing template rendering entirely. CRDs that need templating (different
versions per environment) must be placed in templates/ instead, but
then they won't be pre-checked at install time.
3. Helm Template Functions
Helm uses Go's text/template package extended with the
sprig function library.
Every template expression {{ ... }} is evaluated
by Go's template engine. Understanding the key functions and their gotchas is
essential for writing correct charts.
toYaml and toJson
toYaml converts a value to its YAML string representation.
toJson does the same for JSON. These are among the most commonly
used functions because they let you dump complex structures into fields that
expect a string or map.
# toYaml: serialize a value to a YAML string
# Common use: passing complex objects as string fields
env:
- name: CONFIG
value: {{ .Values.configMap | toYaml | indent 4 }}
# Result: CONFIG becomes a multi-line YAML string
# toYaml with nindent (or indent): formatting for nested YAML blocks
# The nindent function adds newlines before indentation
# indent adds the indent without adding a leading newline
# Example: injecting extra volume mounts
volumes:
{{- toYaml .Values.extraVolumes | nindent 8 }}
# Example: dumping an entire ConfigMap as a string
data:
config.yaml: |
{{- toYaml .Values.configData | nindent 6 }}
# toJson: serialize as JSON
# Useful for fields that expect JSON strings
args:
- --config
- {{ .Values.config | toJson }} tpl (Template String as Template)
tpl evaluates a string as a Helm template. This is powerful for
scenarios where you want users to provide template strings in values that get
re-interpreted against the chart's context.
# values.yaml:
# customAnnotation: |
# my annotation: {{ .Release.Name }}
# template:
metadata:
annotations:
{{- tpl .Values.customAnnotation . }}
# Result: my annotation: myapp (the Release.Name is substituted)
# Another example: user-defined labels that reference chart values
# values.yaml:
# extraLabels:
# app: "{{ .Chart.Name }}"
# version: "{{ .Values.image.tag }}"
# template:
spec:
template:
metadata:
labels:
{{- range $key, $val := .Values.extraLabels }}
{{ $key }}: {{ tpl $val $ }}
{{- end }} include and template
Both include and template invoke a named template.
The critical difference: template outputs raw template output,
while include respects the indentation context when used with
| indent. In practice, prefer include for anything
that needs to be placed inside a YAML block.
# In _helpers.tpl:
{{- define "mychart.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
app.kubernetes.io/managed-by: Helm
{{- end }}
# In templates/deployment.yaml — CORRECT (use include, not template):
metadata:
labels:
{{- include "mychart.labels" . | nindent 4 }}
# include respects the | nindent 4 and indents every line by 4 spaces
# WRONG:
metadata:
labels:
{{- template "mychart.labels" . | nindent 4 }}
# template output is piped through indent, but template doesn't track
# the context properly for multi-line output — you'll get inconsistent results required
required enforces that a value must be present and non-empty.
If the value is empty or nil, Helm stops template rendering and produces an error
at helm install/upgrade time — it will not silently proceed.
This is how charts enforce mandatory user configuration.
# Define a required value
image: {{ required "image.repository is required" .Values.image.repository }}
# If .Values.image.repository is empty, template rendering fails with:
# Error: execution error at (mychart/templates/deployment.yaml:10):
# image.repository is required
# Multiple required values in one template
# templates/deployment.yaml
env:
- name: DATABASE_HOST
value: {{ required "env.DATABASE_HOST must be set" .Values.env.DATABASE_HOST }}
- name: DATABASE_PORT
value: {{ .Values.env.DATABASE_PORT | default "5432" }} default
default sets a fallback value when the provided value is empty
(nil, zero-length string, zero number, false boolean, empty list/map).
The syntax is default <default-value> <provided-value>
— note the argument order is default first, which trips people up.
# Correct usage: default THEN the value
replicas: {{ .Values.replicaCount | default 1 }}
# If .Values.replicaCount is 0 or not set, use 1.
# Image tag: empty string falls back to Chart.AppVersion
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
# WRONG order (common mistake):
# replicas: {{ default .Values.replicaCount 1 }} ← this always returns .Values.replicaCount
# because .Values.replicaCount is the "provided value" and is usually set, even if to 0 lookup Function (Getting Cluster State)
The lookup function queries the cluster at render time (not at
apply time — it's evaluated during helm template and
helm install --dry-run). It returns an object or list of objects
from the cluster. This is useful for conditional logic based on existing
cluster state.
# lookup syntax: lookup "apiVersion" "resource" "namespace" "name"
# All arguments except apiVersion and resource are optional, use "" for unset.
# Check if a Namespace exists
{{- $ns := (lookup "v1" "Namespace" "" "monitoring").metadata.name -}}
{{- if $ns }}
# ... monitoring namespace exists
{{- end }}
# Get the latest version of a DaemonSet in the same namespace
{{- $ds := lookup "apps/v1" "DaemonSet" .Release.Namespace "my-daemonset" -}}
{{- if $ds }}
apiVersion: apps/v1
kind: DaemonSet
...
{{- end }}
# WARNING: lookup is evaluated at template render time (helm template or helm install --dry-run)
# NOT at apply time. This means the condition might not reflect current cluster state
# if the cluster changed between render and apply. Also, 'helm template' runs in your
# local machine's context, not the cluster — if you have no cluster access, lookup fails. 4. Named Templates Deep Dive
Named templates (also called partials or macros) live in _helpers.tpl
and are the mechanism for code reuse within a chart. They replace the "copy-paste
YAML" pattern and enable consistent labeling, common resource shapes, and reusable
utility functions.
define and improving Templates
{{- define "name" }}...{{- end }} defines a
named template. The - hyphens strip whitespace before and after the
block, which is critical for correct YAML indentation. Without the hyphen, you'll
see extra blank lines in rendered YAML.
# _helpers.tpl
{{- /*
Name: mychart.fullname
Usage: {{ include "mychart.fullname" . }}
Returns: release-name-chart-name truncated to 63 chars
*/ -}}
{{- define "mychart.fullname" -}}
{{- $name := printf "%s-%s" .Release.Name .Chart.Name -}}
{{- $name | lower | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- /*
Name: mychart.labels
Usage: {{ include "mychart.labels" . | nindent N }}
Standard labels applied to all resources
*/ -}}
{{- define "mychart.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/component: {{ .Values.labels.component | default "application" }}
{{- end -}}
{{- /*
Name: mychart.selectorLabels
Usage: {{ include "mychart.selectorLabels" . }}
Selector labels — used in Deployment selector and Pod template labels
Must match exactly. No managed-by or version labels here.
*/ -}}
{{- define "mychart.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- /*
Name: mychart.annotations
Usage: {{ include "mychart.annotations" . }}
Standard annotations (excluding last-applied, which Helm doesn't use)
*/ -}}
{{- define "mychart.annotations" -}}
checksum/config: {{ include (printf "%s.config" .Chart.Name) . | sha256sum }}
{{- end -}} Template Inheritance and include Chains
Named templates don't support inheritance in the classical OOP sense, but you can build up complex label/annotation sets by having templates include other templates. This is a common pattern for creating a base template that other templates extend.
# _helpers.tpl — chaining includes for composite labels
# Base labels that every resource gets
{{- define "common.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
# Chart-specific labels extend base labels
{{- define "common.auditLabels" -}}
{{- include "common.labels" . }}
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/component: audit
{{- end -}}
# In templates/audit-deployment.yaml:
spec:
template:
metadata:
labels:
{{- include "common.auditLabels" . | nindent 8 }} Template Scope (. Values)
When you call a named template with include "name" . , the dot (.)
passed as the second argument becomes the template's scope. The called template
gets a fresh . that refers to the value you passed. For subcharts,
the parent chart's values are accessible at .Values from the parent's
perspective — the subchart gets its own .Values which is the subchart's
section of the parent values.yaml.
Accessing Values from Named Templates
# In a parent chart with subchart dependency:
# values.yaml:
# postgresql:
# enabled: true
# auth:
# database: myapp
# A named template in the parent chart needs to reference the subchart values:
# _helpers.tpl
{{- define "mychart.db.url" -}}
{{- $pg := index .Values "postgresql" | default dict -}}
postgres://{{- $pg.auth..database | default "defaultdb" }}
{{- end -}}
# In a subchart, values from parent are NOT automatically visible.
# The subchart's values.yaml has its own top-level keys.
# The parent must explicitly pass values down using the subchart's name or alias.
# Parent values.yaml:
# postgresql: ← must match subchart name or alias
# auth:
# database: myapp
# alias: my-postgres ← in Chart.yaml dependencies, this changes the key
# In parent templates, if you use alias: my-postgres, access via:
{{- $pg := index .Values "my-postgres" | default dict -}} 5. Chart Dependencies
Helm charts can depend on other charts. The dependency declaration in
Chart.yaml uses the dependencies: block, which
in Helm 2 was called requirements.yaml — Helm 3 merges both into
Chart.yaml.
Dependencies in Chart.yaml
# Chart.yaml
apiVersion: v2
name: myapp
version: 1.0.0
dependencies:
- name: postgresql
version: "14.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled # top-level flag to enable/disable
tags:
- database
- storage
- name: redis
version: "19.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled
tags:
- cache
- name: monitoring
version: "6.x.x"
repository: "https://prometheus-community.github.io/helm-charts"
import-values:
- child: commonLabels
parent: labels # map child values up to parent
alias: mon # alias for shorthand access in values Dependency Management Commands
# Download all dependencies based on Chart.yaml
# Creates/updates charts/ directory and requirements.lock
$ helm dependency build
# Update dependencies to latest matching version constraints
$ helm dependency update
# List current dependencies
$ helm dependency list
NAME VERSION REPOSITORY
postgresql 14.5.2 https://charts.bitnami.com/bitnami
redis 19.0.4 https://charts.bitnami.com/bitnami
# The lock file: requirements.lock
# Generated by `helm dependency build`. Commit this to ensure reproducible builds.
# Do NOT edit manually — run `helm dependency build` after Chart.yaml changes. Importing Values from Subcharts
When a parent chart needs to propagate global values to a subchart, the
import-values field controls how subchart values are merged into
the parent chart's values structure. This is one of the most confusing aspects
of Helm chart authoring.
# Subchart (named "monitoring") has values:
# commonLabels:
# env: production
# team: platform
# Parent Chart.yaml declares:
# dependencies:
# - name: monitoring
# alias: mon
# import-values:
# - child: commonLabels
# parent: labels
# This maps the subchart's commonLabels → parent's labels namespace.
# After merge, parent values contain:
# labels:
# env: production
# team: platform
# Alternative: automatic parent-child key mapping
# If the child has a key that matches the subchart name, it's auto-imported
# Subchart "monitoring" has key "monitoring.commonLabels"
# → parent values.monitoring.commonLabels is accessible Conditions and Tags
conditions and tags are two mechanisms for
enabling/disabling dependencies. Conditions are more precise; tags are
for grouping.
# Condition: requires a specific top-level boolean to be true
dependencies:
- name: postgresql
condition: postgresql.enabled # requires values.postgresql.enabled: true
# Tag: any matching tag enables all charts with that tag
dependencies:
- name: monitoring
tags:
- observability
- name: logging
tags:
- observability
# values.yaml:
# tags:
# observability: true
# This enables BOTH monitoring and logging charts simultaneously Hook Ordering with Dependencies
Dependencies are installed before the parent chart by default. Hooks in
dependency charts run before hooks in the parent chart. However, you can
control the relative ordering of hooks across charts using the
helm.sh/hook-weight annotation — lower weight hooks run first.
6. Helm Release Lifecycle
Understanding the exact sequence of operations during helm install,
helm upgrade, and helm uninstall is essential for
writing hooks that work correctly and understanding when resources are created,
updated, or deleted.
helm install Lifecycle
- Chart loading: Helm reads the chart directory or fetches from repo/OCI
- Values merge: Default values.yaml merged with user values files and --set flags
- Template rendering: All templates in
templates/rendered with the merged values - CRDs applied: Files from
crds/applied to cluster (in alphabetical order) - Pre-install hooks: Hook resources (Job, Pod, etc.) with
helm.sh/hook: pre-installapplied and waited for - Resources applied: Regular templates sent to API server via kubectl
- Post-install hooks: Hook resources with
helm.sh/hook: post-installapplied and waited for - Release created: Release state recorded in a new Secret (v1)
- NOTES.txt printed: Content of
templates/NOTES.txtshown to user
helm upgrade Lifecycle
- Chart loading and values merge
- Pre-upgrade hooks:
helm.sh/hook: pre-upgraderun and waited for - CRDs applied: Any new CRDs added to
crds/applied - 3-way diff: Helm computes the delta between previous release, live state, and new manifest
- Resources applied: Patch/apply changes from the diff
- Post-upgrade hooks:
helm.sh/hook: post-upgraderun and waited for - Release updated: New Secret created with incremented revision number
- NOTES.txt printed
helm uninstall Lifecycle
- Pre-delete hooks:
helm.sh/hook: pre-deleterun and waited for - Resources deleted: All Kubernetes resources managed by the release deleted (in reverse dependency order)
- Release marked deleted: Latest Secret updated with
deletedstatus - Post-delete hooks:
helm.sh/hook: post-deleterun and waited for - Hook resources: Hooks are NOT deleted by default (see hook delete policy)
helm rollback
# Rollback to previous revision
$ helm rollback myapp -n mynamespace
# Rollback to specific revision
$ helm rollback myapp 3 -n mynamespace
# Rollback lifecycle (same as upgrade but with previous revision's manifest):
# 1. Pre-rollback hooks
# 2. Apply previous revision's manifests
# 3. Post-rollback hooks
# 4. New revision created (not overwriting the failed one)
# List available revisions
$ helm history myapp -n mynamespace
REVISION STATUS DESCRIPTION
1 superseded Install complete
2 superseded Upgrade complete
3 deployed Rollback to 1 complete
4 failed Upgrade failed — see logs 7. Hooks Deep Dive
Hooks are Kubernetes resources (Jobs, Pods, Jobs, CronJobs) annotated with
helm.sh/hook to participate in Helm's release lifecycle. They
are the mechanism for database migrations, configuration loading, and
pre/post-deploy operations.
Hook Annotation Reference
# helm.sh/hook: one or more comma-separated hook names
annotations:
helm.sh/hook: pre-install,post-install,pre-upgrade,post-upgrade,pre-delete,post-delete,test
# Available hooks:
# pre-install Runs after CRDs are applied, before templates are applied
# post-install Runs after templates are applied, after install completes
# pre-upgrade Runs before upgrade templates are applied
# post-upgrade Runs after upgrade templates are applied
# pre-delete Runs before any resources are deleted
# post-delete Runs after all resources are deleted
# test Runs when `helm test` is executed (see Section 8)
# helm.sh/hook-weight: integer (default 0)
# Lower weight runs first. Use negative for "always first" or high for "always last".
annotations:
helm.sh/hook-weight: "-1" # run before most other hooks
# helm.sh/hook-delete-policy: comma-separated policy list
# Options: before-hook-creation, hook-succeeded, hook-failed, hook-skipped
annotations:
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
# before-hook-creation: delete previous version of this hook before applying
# hook-succeeded: delete the hook resource after it succeeds
# hook-failed: delete the hook resource if it fails
# hook-skipped: skip the hook if the release is in a non-deploying state (e.g., rollback) Practical Hook Examples: Database Migrations
# templates/jobs/migrate-db.yaml
# Pre-upgrade hook that runs a database migration before deploying new app version
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-migrate
annotations:
helm.sh/hook: pre-upgrade
helm.sh/hook-weight: "-1" # run BEFORE the deployment update
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
labels:
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/name: {{ .Chart.Name }}
spec:
backoffLimit: 3 # retry the migration up to 3 times
activeDeadlineSeconds: 300 # fail if it takes more than 5 minutes
template:
metadata:
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
spec:
restartPolicy: OnFailure
serviceAccountName: {{ .Release.Name }}-migrate-sa
containers:
- name: migrate
image: {{ .Values.migrate.image | default .Values.image.repository }}
command: ["python", "-m", "alembic", "upgrade", "head"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-db-secret
key: url
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi Hook ServiceAccount
Hook Jobs run in the cluster and need a ServiceAccount with
appropriate RBAC permissions. The ServiceAccount and RBAC bindings should
be defined as non-hook resources in templates/ so they're
always present before the hook Job runs.
# templates/rbac/hook-sa.yaml
# Non-hook resources are created before hooks run, so this SA is available for hooks
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Release.Name }}-hook-sa
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-delete-policy": before-hook-creation
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ .Release.Name }}-migrate-role
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-delete-policy": before-hook-creation
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ .Release.Name }}-migrate-rb
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-delete-policy": before-hook-creation
subjects:
- kind: ServiceAccount
name: {{ .Release.Name }}-hook-sa
namespace: {{ .Release.Namespace }}
roleRef:
kind: Role
name: {{ .Release.Name }}-migrate-role
apiGroup: rbac.authorization.k8s.io Waiting for Hooks to Complete
By default, Helm waits for all hook resources to complete successfully before
proceeding to the next lifecycle step. For Jobs, this means Helm watches the Job
until all pods succeed. If the Job fails (non-zero exit), Helm marks the install/upgrade
as failed. The timeout for waiting is controlled by --timeout flag
(default 5m0s).
# Run helm install with custom timeout
$ helm install myapp ./mychart \
--timeout 10m0s \ # wait up to 10 minutes for hooks
--wait \ # also wait for pods to be ready (non-hook resources)
--wait-timeout 5m0s # separate timeout for --wait
# A hook Job that fails doesn't automatically rollback
# The release is marked 'failed' but the previous successful release is still active.
# To auto-rollback on hook failure:
# There is no built-in flag. Implement this by:
# 1. Using a pre-install hook that validates prerequisites, failing early
# 2. Using a post-install hook that, if it fails, doesn't block the deploy but alerts
# Hook retry behavior:
# Job with backoffLimit: 3 will be retried up to 4 times total
# Helm marks the hook as failed only after all retries exhaust 8. Testing Charts
Helm has a built-in test framework using the helm test command.
Tests are hook resources with helm.sh/hook: test that run after
a successful release installation.
Test Pods
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: {{ .Release.Name }}-test-connection
annotations:
helm.sh/hook: test # registers this as a test hook
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
restartPolicy: Never # required for test pods
containers:
- name: wget
image: curlimages/curl:latest
command:
- sh
- -c
- |
echo "Testing endpoint..."
wget -q --spider http://{{ .Release.Name }}:{{ .Values.service.port }} || exit 1
echo "Connection successful"
# Alternatively, use a dedicated test image
# - name: test
# image: ghcr.io/myorg/test-runner:v1
# command: ["./test.sh", "--url", "http://{{ .Release.Name }}:{{ .Values.service.port }}"] Running Tests
# Run all tests for a release
$ helm test myapp -n mynamespace
# With output:
$ helm test myapp -n mynamespace --stdout --debug
# Output:
# NAME: myapp
# LAST DEPLOYED: ...
# NAMESPACE: mynamespace
# STATUS: deployed
# RESOURCES:
# ==> v1/Pod/myapp-test-connection
# NAME: myapp-test-connection
# PASS Hello World test
#
# 1/1 tests passed
# Run tests with more verbose output
$ helm test myapp -n mynamespace --filter name=test-*
# Cleanup: tests run on every `helm test`; hook-succeeded delete policy cleans up after
# If tests fail, the Pod stays for debugging
$ kubectl logs myapp-test-connection -n mynamespace
$ kubectl describe pod myapp-test-connection -n mynamespace Test Best Practices
- Use
helm.sh/hook-delete-policy: before-hook-creation,hook-succeededto auto-cleanup on success - Test pods should be lightweight — use
curlimages/curlor a dedicated test image - Keep test logic in the container image, not the template, for reproducibility
- Write tests that verify the deployment is working, not just that YAML is valid
- Use
restartPolicy: Never— Helm requires this for test pods - Test pods should exit 0 on success, non-zero on failure (standard shell convention)
Environment Setup Tests (pre-install hooks)
# A pre-install hook that validates the target namespace is correctly configured
apiVersion: v1
kind: Pod
metadata:
name: {{ .Release.Name }}-validate-env
annotations:
helm.sh/hook: pre-install
helm.sh/hook-weight: "-2" # run early
helm.sh/hook-delete-policy: before-hook-creation,hook-failed
spec:
restartPolicy: Never
containers:
- name: validate
image: bitnami/kubectl:latest
command:
- sh
- -c
- |
echo "Validating webhook configuration..."
# Check that required webhooks exist in the cluster
kubectl get validatingwebhookconfigurations {{ .Release.Name }}-validation 2>/dev/null && echo "Webhooks OK" || (echo "Missing webhooks" && exit 1)
# Check storage class exists
kubectl get storageclass {{ .Values.persistence.storageClass }} && echo "Storage OK" || (echo "Storage class missing" && exit 1) 9. Library Charts
A library chart is a chart that provides only templates — no Kubernetes resources are deployed when it's included as a dependency. Library charts are the mechanism for sharing template code across multiple consuming charts. The Bitnami common chart is the canonical example.
# Library chart — Chart.yaml
apiVersion: v2
name: mylib
version: 1.0.0
type: library # ← this is what makes it a library chart
# Library chart — _helpers.tpl
# No values.yaml with defaults (that's fine for a library)
# The templates in a library chart are made available to any chart that depends on it
# When you `include "mylib.some-template" .`, it runs in your chart's context
# Using a library chart in a consuming chart:
# Chart.yaml:
# dependencies:
# - name: mylib
# version: "1.x.x"
# repository: "https://myreg.io/charts"
# condition: mylib.enabled
# In templates/deployment.yaml:
# {{- $fullName := include "mylib.fullname" . -}}
# This calls mylib's fullname template with the consuming chart's scope The Bitnami Common Library Chart
The Bitnami common library chart provides a large collection of reusable templates: label helpers, image pull policy logic, PVC size parsing, ingress helpers, and more. Many Bitnami charts depend on it, and you can use it in your own charts for consistent resource definitions.
# In your chart's Chart.yaml:
# dependencies:
# - name: common
# version: "2.x.x"
# repository: "https://charts.bitnami.com/bitnami"
# In templates/_helpers.tpl:
# Import Bitnami common helpers
{{- include "common.matchLabels" . -}} # provided by bitnami/common When to Use Library Charts
_helpers.tpl content, or you want to distribute reusable template
utilities across an organization.
10. Chart Distribution: OCI, ChartMuseum, and Harbor
Helm charts can be distributed through traditional HTTP chart repositories (the historical approach) or as OCI artifacts in container registries (the modern approach, added in Helm 3.8). Understanding both helps you choose the right distribution strategy for your organization.
OCI Registry Distribution
OCI (Open Container Initiative) distribution treats Helm charts as container images. Charts are pushed to and pulled from container registries using the same authentication as Docker images. This unifies your image and chart management workflow.
# Login to OCI registry (uses Docker credentials)
$ helm registry login ghcr.io -u my-user
# Package and push a chart
$ helm package ./mychart
# Creates mychart-1.2.3.tgz
$ helm push mychart-1.2.3.tgz oci://ghcr.io/myorg/charts
# Pushes as: ghcr.io/myorg/charts/mychart:1.2.3
# Chart name becomes part of the OCI artifact name
# Install from OCI registry
$ helm install myapp oci://ghcr.io/myorg/charts/mychart --version 1.2.3 -n mynamespace
# Pull without installing
$ helm pull oci://ghcr.io/myorg/charts/mychart --version 1.2.3
# OCI with authentication
# Helm uses Docker config.json (~/.docker/config.json) for auth
# Run `docker login` or `helm registry login` to authenticate
# Registry aliases (configure in ~/.config/helm/registries.yaml)
# GHCR: ghcr.io, GCR: gcr.io, ECR: aws_account.dkr.ecr.region.amazonaws.com ChartMuseum (Self-Hosted Chart Repository)
ChartMuseum is a Go-built chart repository
server with an HTTP API. It stores charts in object storage (S3, GCS, Azure Blob,
Alibaba OSS, etc.) and serves the index.yaml that Helm expects.
It's the open-source answer to ChartMusuem (the original project) and is often
used in air-gapped environments.
# Install ChartMuseum via its official chart
$ helm install chartmuseum chartmuseum/chartmuseum \
--set persistence.enabled=true \
--set persistence.size=10Gi \
--set env.open.DEPrecatedStorageBackend="swift" \
--set env.open.STORAGE_swift_authurl="https://auth.someswift.com/v3/auth" \
--set env.open.STORAGE_swift_username="user" \
--set env.open.STORAGE_swift_password="pass"
# Push a chart to ChartMuseum (using chartmuseum CLI or curl)
$ curl -u : --data-binary @mychart-1.0.0.tgz \
https://chartmuseum.mycompany.com/api/charts
# Add the repo to Helm
$ helm repo add myrepo https://chartmuseum.mycompany.com --username admin --password secret
$ helm repo update
# Install from the repo
$ helm install myapp myrepo/mychart --version 1.0.0 Harbor (OCI Registry with Chart Support)
Harbor is an open-source container registry that supports OCI artifacts including Helm charts. It provides vulnerability scanning, image signing (with Notary/cosign), role-based access control, and replication across registries. Harbor is the common choice for enterprise chart hosting.
# Push to Harbor OCI project
# Harbor projects function like namespaces for artifacts
$ helm package ./mychart
$ helm push mychart-1.0.0.tgz \
oci://harbor.mycompany.com/myproject/charts/mychart
# Add Harbor as an OCI registry (Helm needs credentials)
$ helm registry login harbor.mycompany.com -u admin
# Install from Harbor
$ helm install myapp \
oci://harbor.mycompany.com/myproject/charts/mychart \
--version 1.0.0 \
-n mynamespace
# Harbor also supports traditional chart repo via its chartmuseum API
# Configure a project to be chart repository enabled:
# Projects → myproject → Replication → create chartmuseum endpoint
# Add as a Helm repo:
$ helm repo add myproject https://harbor.mycompany.com/chartrepo/myproject
$ helm repo update Traditional HTTP Repository
The original Helm chart distribution mechanism uses an HTTP server that serves
an index.yaml file alongside the packaged chart tarballs. Many
public charts are distributed this way through ArtifactHub.
# Serve a local directory as a chart repo
$ mkdir -p chart-repo && cd chart-repo
$ helm package ../mychart
$ helm repo index . --url https://charts.mycompany.com
# Creates index.yaml listing all charts in the directory
# Serve over HTTP (any static file server works)
$ python3 -m http.server 8080 --directory . &
$ curl http://localhost:8080/index.yaml | head -50
# Add the repo
$ helm repo add mycompany https://charts.mycompany.com
$ helm repo update
# Search the repo
$ helm search repo mycompany 11. Helmfile and Multi-Environment IaC
Helmfile is a declarative specification for deploying Helm charts across multiple environments. It layers on top of Helm to solve the multi-cluster, multi-environment configuration problem that Helm alone doesn't address well.
Helmfile Basics
# helmfile.yaml
# Defines releases across environments
repositories:
- name: bitnami
url: https://charts.bitnami.com/bitnami
- name: prometheus-community
url: https://prometheus-community.github.io/helm-charts
releases:
# Base deployment present in all environments
- name: nginx
chart: bitnami/nginx
values:
- replicas: 2
# Production-specific overrides
- name: myapp
chart: ./charts/myapp
version: 1.2.3
namespace: myapp
values:
- values/prod.yaml.gotmpl # gotmpl suffix enables template rendering
secrets:
- values/prod-secrets.yaml.gotmpl # encrypted with sops
hooks:
- events: ["post-install", "post-upgrade"]
command: "python"
args: ["scripts/notify-deploy.py", "{{ .Release.Name }}", "{{ .Values.env }}"] Environment Configuration
# helmfile.yaml — environment inheritance
environments:
prod:
values:
- values/prod.yaml.gotmpl
secrets:
- values/prod-secrets.enc.yaml # age/sops encrypted
context: my-cluster-prod
staging:
values:
- values/staging.yaml.gotmpl
secrets:
- values/staging-secrets.enc.yaml
context: my-cluster-staging
dev:
values:
- values/dev.yaml.gotmpl
context: my-cluster-dev
# Select environment at runtime
$ helmfile -e prod apply
$ helmfile -e staging diff
$ helmfile -e dev template | less # render without applying
# Override values at CLI
$ helmfile -e prod apply --set myapp.replicas=10 Release Templates (DRY across environments)
# Use release templates to avoid repetition
templates:
defaultMetadata: &defaultMetadata
chart: ./charts/myapp
namespace: myapp
releases:
- name: myapp-prod
<<: *defaultMetadata
version: 1.2.3
values:
- values/prod.yaml.gotmpl
environment: prod
- name: myapp-staging
<<: *defaultMetadata
version: 1.2.3
values:
- values/staging.yaml.gotmpl
environment: staging
- name: myapp-dev
<<: *defaultMetadata
version: 1.2.3
values:
- values/dev.yaml.gotmpl
environment: dev Alternatives to Helmfile
- Helm + Kustomize: Helm for templating, Kustomize for environment overlays (
helm template | kubectl apply -k -) - ArgoCD Application: GitOps-driven Helm deployments with ArgoCD, using Application manifests to point at chart repos
- Flux HelmRelease: Flux CD's HelmRelease custom resource reconciles Helm installations from Git
- Ansible helm: Ansible playbooks that invoke the Helm CLI for configuration management
- Terraform Helm provider: Terraform manages Helm releases as resources, useful for GitOps workflows that use Terraform
12. Function and Filter Reference
A quick reference for the most commonly used template functions in Helm
charts, grouped by category. All functions come from Go's
text/template and the sprig library.
String and Printing Functions
# printf — format a string (like fmt.Sprintf in Go)
{{- printf "my-%s-v%d" .Release.Name 1 }} # → myapp-v1
{{- printf "%s-%s" .Release.Name .Chart.Name }}
# quote / squote — wrap in double/single quotes
{{ .Values.key | quote }} # → "value"
{{ .Values.key | squote }} # → 'value'
# upper, lower, title, substr
{{ .Release.Name | upper }} # → MYAPP
{{ .Release.Name | lower }} # → myapp
{{ "hello world" | title }} # → Hello World
{{ "abcdef" | substr 0 3 }} # → abc
# trim, trimPrefix, trimSuffix
{{ " hello " | trim }} # → hello
{{ "my-app-v1" | trimPrefix "my-" }} # → app-v1
# replace
{{ "my-app" | replace "-" "_" }} # → my_app Logic and Comparison Functions
# eq, ne (equals, not equals)
{{- if eq .Values.env "production" }}
production-specific config
{{- else if ne .Values.env "development" }}
non-development config
{{- end }}
# and, or, not
{{- if and .Values.service.enabled .Values.service.ingress.enabled }}
...
{{- end }}
# empty (checks if value is nil, empty string, zero, false, empty collection)
{{- if empty .Values.someKey }}
key is not set or is falsy
{{- end }}
# default — set fallback value
{{ .Values.replicaCount | default 1 }}
# coalesce — first non-empty value from a list
{{- coalesce .Values.image.tag .Chart.AppVersion "latest" }} Collection Functions (Lists, Maps, Ranges)
# range over a map
{{- range $key, $val := .Values.env }}
- name: {{ $key | quote }}
value: {{ $val | quote }}
{{- end }}
# .Values.env = {MODE: prod, LOG_LEVEL: info}
# → - name: "MODE" value: "prod"
# range over a list
{{- range .Values.ports }}
- containerPort: {{ . }}
{{- end }}
# index — access item by index/key
{{ index .Values.arr 0 }} # first element
{{ index .Values.map "key" }} # value for "key"
# has .Values.something (check if value exists in list)
{{ has "mystring" .Values.list }} # true/false
# list — create a list literal
{{ list "a" "b" "c" }} # → [a b c]
# pick — select keys from a map
{{ .Values.config | pick "host" "port" }}
# omit — exclude keys from a map
{{ .Values.config | omit "password" }} Type Conversion Functions
# toYaml — convert value to YAML string
# toJson — convert value to JSON string
# toToml — convert value to TOML string
# toJson pretty (indent JSON output)
{{ .Values.config | toYaml }}
{{ .Values.config | toJson }}
# int, float64, bool — type casting
{{ .Values.replicas | int }} # force integer
{{ .Values.timeout | int | mul 1000 }} # ms conversion
{{ .Values.enabled | bool }} # force boolean Math Functions
# add, sub, div, mul, mod
{{ 1 | add 2 }} # → 3
{{ 6 | div 2 }} # → 3
{{ 3 | mul 4 }} # → 12
{{ 7 | mod 3 }} # → 1
# floor, ceil, round
{{ 3.7 | floor }} # → 3
{{ 3.2 | ceil }} # → 4
# max, min, abs
{{ .Values.replicas | max 10 }} # at most 10
{{ .Values.replicas | min 1 }} # at least 1 Date and Time Functions
# now — current time
{{ now | date "2006-01-02" }} # → 2026-05-22
# dateModify — modify a date
{{ now | dateModify "-24h" }}
# date functions use Go date formatting (reference date: Mon Jan 2 15:04:05 2006) 13. Conditional Blocks and Range Loops In Depth
Go templates' control structures are the most common source of bugs in Helm
charts. The -}} whitespace control syntax, the scope of
. inside loops, and the behavior of with versus
if all have subtle behaviors that cause silent breakage.
Whitespace Control (− and =)
The - prefix inside {{ strips leading whitespace
from the output, and the - suffix inside }} strips
trailing whitespace. Without these, template output includes the newlines between
template tags, which breaks YAML indentation (indent levels get extra blank lines).
# Without whitespace control (BAD — produces extra blank lines in YAML)
spec:
containers:
- name: app
image: nginx
{{ range .Values.args }}
arg: {{ . }}
{{ end }}
# Output has blank lines between the arg entries due to template tags being on separate lines
# With whitespace control (GOOD)
spec:
containers:
- name: app
image: nginx
{{- range .Values.args }}
arg: {{ . }}
{{- end }}
# The - strips the preceding newline from the range output with vs if: Scope Differences
# with: changes the scope of . to the specified value
{{- with .Values.image }}
# Inside this block, . refers to .Values.image
repository: {{ .repository }} # same as .Values.image.repository
tag: {{ .tag | default "latest" }}
{{- else }}
repository: nginx
tag: latest
{{- end }}
# Note: . outside of the with block is still accessible
# but you cannot reference .Values inside the else from within with
# if: does NOT change scope — . still refers to the root value
{{- if .Values.image }}
# Inside this block, . still refers to the top-level root value
repository: {{ .Values.image.repository }} # must use full path
{{- end }}
# Practical rule: use 'with' when you want to access nested properties repeatedly
# use 'if' when you only need to check existence or truthiness Range Loop Scope
# Range over a map — $key, $val syntax
env:
{{- range $key, $value := .Values.env }}
- name: {{ $key | quote }}
value: {{ $value | quote }}
{{- end }}
# Range over a list — . is the current item
ports:
{{- range .Values.service.ports }}
- name: {{ .name | default "http" }}
port: {{ .port }}
targetPort: {{ .targetPort | default .port }}
{{- end }}
# Nested range (multiple iterations)
# When you nest ranges, innermost . is the innermost item
# Use $root or $. to access the top-level value
volumes:
{{- range $i, $vol := .Values.volumes }}
- name: {{ $vol.name }}
{{- range $j, $mount := $vol.mounts }}
mountPath: {{ $mount.path }} # $vol still accessible
{{- end }}
{{- end }}
# Empty range: loop runs zero times, no output (unlike with where you need else)
# range always produces zero iterations for empty/nil — no special else needed Break and Continue in Loops
Helm templates don't have break or continue keywords.
Instead, you filter the collection before iterating using where
(sprig) or by constructing the range with a conditional.
# Filter list before range
{{- range list "a" "b" "c" | filter "b" }}
# Only iterates "b" — filter removes non-matching items
# Or use if/continue pattern (less elegant but works)
{{- range .Values.items }}
{{- if eq . "skip" }}
{{- continue }}
{{- end }}
# process item
{{- end }}
# Note: continue is a valid sprig function for this purpose 14. Upgrade Strategy for Zero-Downtime Deployments
Production Helm deployments require careful planning to avoid downtime during upgrades. This section covers atomic upgrade patterns, rollback strategy, and hook-based deployment safety.
Atomic Upgrades with Rollback on Failure
# Basic atomic upgrade: test first, deploy second
$ helm upgrade myapp ./mychart -n mynamespace \
--atomic \ # auto-rollback if upgrade fails
--timeout 5m0s
# --atomic waits for all resources to be ready before considering the upgrade successful
# If the timeout is reached before readiness, helm rolls back to the previous revision
# Separate hook timeout from resource readiness timeout
$ helm upgrade myapp ./mychart \
--timeout 10m0s \ # hook wait timeout (5m default)
--wait \ # wait for resource readiness
--wait-timeout 5m0s # resource readiness timeout Using Hooks for Safe Migrations
# Pattern: pre-upgrade hook runs schema migration before new app deploys
# templates/jobs/pre-upgrade-migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-pre-upgrade-migrate
annotations:
helm.sh/hook: pre-upgrade
helm.sh/hook-weight: "-1"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: {{ .Values.migrate.image }}
command:
- /bin/sh
- -c
- |
# Run migration with retries
for i in 1 2 3; do
./migrate.sh && exit 0
sleep 5
done
exit 1
env:
- name: DB_URL
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-db
key: url
---
# Deployment template uses updateStrategy to minimize disruption
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25% # allow 25% extra pods during rollout
maxUnavailable: 0 # zero downtime — never reduce available count
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}
template:
spec:
terminationGracePeriodSeconds: 60 # give app time to finish requests Rollback Planning
# Check what changed between revisions before rolling back
$ helm diff revision myapp 3 2 -n mynamespace
# Shows the diff between revision 3 and revision 2
# Rollback to known good revision
$ helm rollback myapp 2 -n mynamespace
# Rollback lifecycle: pre-rollback hooks → apply old manifest → post-rollback hooks
# History shows full timeline
$ helm history myapp -n mynamespace
REVISION STATUS DESCRIPTION
1 superseded Installed
2 superseded Upgraded to 1.2.0
3 superseded Upgraded to 1.3.0 — rolled back
4 deployed Rollback to 2
# Preserve rollback history (don't let it grow unbounded)
# In Helm 3, default history limit is 256 revisions per release
# Configured via --history-max flag on install/upgrade
$ helm upgrade myapp ./mychart --history-max 10 -n mynamespace
# Oldest revisions beyond the limit are pruned Canary Deployment Pattern
# Canary: deploy new version alongside current, shift traffic gradually
# This uses a weighted Service to split traffic
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-canary
labels:
app: {{ .Release.Name }}
track: canary
spec:
ports:
- port: 80
selector:
app: {{ .Release.Name }}
track: canary
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-canary
spec:
replicas: 1 # small canary fleet
selector:
matchLabels:
app: {{ .Release.Name }}
track: canary
template:
metadata:
labels:
app: {{ .Release.Name }}
track: canary
spec:
containers:
- name: app
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}-
# Production Traffic: shift 10% → 50% → 100% to canary via weighted routing
# Then promote: helm upgrade main-deployment with same image as canary 15. Practical .Values Patterns for Production
Well-structured values files make charts reusable across environments and teams. These patterns reflect what works in production at scale.
Hierarchical Override Pattern
# base-values.yaml — shared defaults across all environments
image:
repository: ghcr.io/myorg/myapp
pullPolicy: IfNotPresent
tag: "" # use AppVersion by default
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
replicaCount: 2
service:
type: ClusterIP
port: 8080
# development-values.yaml
replicaCount: 1
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
image:
tag: "latest" # always use latest in dev
# staging-values.yaml
replicaCount: 2
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
image:
tag: "staging-latest"
# production-values.yaml
replicaCount: 5
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
autoscaling:
enabled: true
minReplicas: 5
maxReplicas: 20
service:
type: LoadBalancer
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod Global Values and cross-chart Communication
# values.yaml (parent chart)
# Global values are accessible from all subcharts
global:
imageRegistry: ghcr.io
imagePullSecrets:
- name: myreg-creds
labels:
environment: production
# Subchart accesses global values as:
# .Values.global.imageRegistry
# In parent chart's _helpers.tpl, you can reference global via .Values.global
{{- define "common.globalLabels" -}}
environment: {{ .Values.global.labels.environment }}
{{- end -}} JSON Schema Validation
Helm 3 supports values.schema.json to validate values before
template rendering. This catches configuration errors early instead of during
apply.
# values.schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 1
},
"image": {
"type": "object",
"properties": {
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string" },
"pullPolicy": {
"type": "string",
"enum": ["IfNotPresent", "Always", "Never"],
"default": "IfNotPresent"
}
},
"required": ["repository"]
},
"resources": {
"type": "object",
"properties": {
"limits": {
"type": "object",
"properties": {
"cpu": { "type": "string", "pattern": "^[0-9]+m?$" },
"memory": { "type": "string", "pattern": "^[0-9]+(Mi|Gi)$" }
}
}
}
}
}
}
# Schema validation runs during:
# helm install --validate (default in 3.7+)
# helm template --validate
# helm upgrade --validate 16. Debugging Failed Templates
When template rendering fails or produces unexpected output, Helm provides several tools for diagnosing the problem before you apply anything to the cluster.
helm template (Local Rendering)
# Render chart without applying (local, no cluster required)
$ helm template myapp ./mychart
# Renders to stdout, all templates processed
# With values override
$ helm template myapp ./mychart \
-f values-prod.yaml \
--set replicaCount=5
# With namespace (needed for some cluster-aware templates)
$ helm template myapp ./mychart --namespace mynamespace
# Render using remote chart
$ helm template myapp bitnami/nginx --version 15.0.0
# Render to specific directory (useful for inspection)
$ helm template myapp ./mychart --output-dir ./rendered
# Creates rendered/myapp/templates/*.yaml files helm install --dry-run (Server-Side Rendering)
# Server-side dry-run: templates are rendered by Helm SDK on the server
# Requires cluster access (validates against API server for CRD checks)
$ helm install myapp ./mychart -n mynamespace --dry-run
# --dry-run with --debug shows the rendered manifests
$ helm install myapp ./mychart -n mynamespace --dry-run --debug
# The debug output shows the full rendered YAML before it's applied
# This is the most accurate test of what will actually happen on the cluster
# Combined with kubeconfig override
$ helm install myapp ./mychart --dry-run \
--kubeconfig /path/to/prod-kubeconfig \
--namespace mynamespace helm lint
# Lint chart for structural issues
$ helm lint ./mychart
# ==> Linting ./mychart
# [INFO] Chart.yaml: icon is recommended
# [ERROR] templates/deployment.yaml: template: string has unintended newline at start
#
# Lint with strict mode
$ helm lint ./mychart --strict
# Catches more issues including schema validation failures
# Lint with values
$ helm lint ./mychart -f values-prod.yaml Common Template Errors
When you use nindent (or | indent) inside a YAML block
with the template tag on its own line, the newline before the tag becomes part
of the output. Fix: use whitespace control {{- to strip it.
# WRONG:
env:
{{ range .Values.env }}
- name: {{ .name }}
value: {{ .value }}
{{ end }}
# Produces blank line before the env entries
# CORRECT:
env:
{{- range .Values.env }}
- name: {{ .name }}
value: {{ .value }}
{{- end }} If .Values.image.tag is "" (empty string), the template
renders the field as empty but not absent. For fields that expect a value, this
can cause validation errors on apply. Use | default "some-default"
or | required "..." to handle this.
YAML distinguishes between replicas: 3 (integer) and
replicas: "3" (string). Some fields require an integer.
If .Values.replicaCount is a string "3" and it's placed in a
numeric field, Kubernetes might reject it or coerce it. Use | int
filter to force integer type.
Inspecting Merged Values
# Print the effective values (defaults + user overrides) without rendering
$ helm show values ./mychart
# Shows the chart's values.yaml (not the merged values)
# Show values with all subchart values
$ helm template myapp ./mychart | grep -A 1000 "^# Source:" | head -100
# Debug values by adding a temporary template
# templates/tests/show-values.yaml
# {{- /* TEMPORARY: shows all .Values */ -}}
# debug: {{ .Values | toYaml }}
# Run helm template and look for the debug output
# Use the sprig 'remark' function to emit commented debug info into the YAML
# (comments don't affect resource validity)
# # DEBUG values: {{ .Values | toYaml | indent 2 }} Inspecting Release State
# Get release status and history
$ helm status myapp -n mynamespace
$ helm history myapp -n mynamespace
# Get the actual manifests from a release revision
$ helm get manifest myapp -n mynamespace --revision 3
# Shows what was actually deployed for revision 3
# Get all values for a release
$ helm get values myapp -n mynamespace
# Shows the user-provided values (not the chart defaults)
# Get hooks for a release
$ helm get hooks myapp -n mynamespace
# Shows all hook resources and their status 17. NOTES.txt: Post-Install Instructions
templates/NOTES.txt is a Go template that Helm prints to stdout
after a successful install/upgrade. It's the right place for connection details,
next steps, and environment-specific information.
# templates/NOTES.txt
{{- /*
NOTES.txt for myapp
Automatically printed after install/upgrade
*/ -}}
Thank you for installing {{ .Chart.Name }} v{{ .Chart.Version }}
Your release is named as {{ .Release.Name }} and is installed in namespace {{ .Release.Namespace }}.
** Please be patient while the chart is being deployed **
To learn more about the release, try:
$ helm status {{ .Release.Name }} -n {{ .Release.Namespace }}
$ helm get all {{ .Release.Name }} -n {{ .Release.Namespace }}
{{- if .Values.ingress.enabled }}
Application URL{{ if .Values.ingress.multiple }}s{{ end }}:
{{- range .Values.ingress.hosts }}
- https://{{ .host }}{{ if $.Values.ingress.path }}/{{ $.Values.ingress.path }}{{ else }}/{{ end }}
{{- end }}
{{- else }}
To get the application URL, run:
$ kubectl get po -n {{ .Release.Namespace }}
$ kubectl port-forward svc/{{ .Release.Name }} 8080:{{ .Values.service.port }}
Then visit http://localhost:8080
{{- end }}
{{- if .Values.migrate.enabled }}
IMPORTANT: Database migration required. Run:
$ kubectl apply -f {{ .Release.Name }}-migrate.yaml
Check migration status:
$ kubectl get job {{ .Release.Name }}-migrate -n {{ .Release.Namespace }}
{{- end }}
To uninstall this chart:
$ helm uninstall {{ .Release.Name }} -n {{ .Release.Namespace }} Frequently Asked Questions
Helm 2 vs Helm 3 — what changed?
Helm 2 had a server-side component called Tiller that ran in the cluster with cluster-admin privileges and proxied chart installs. It was a security disaster: anyone who could talk to Tiller could escalate to admin. Helm 3 (2019) removed Tiller entirely. The CLI talks directly to the Kubernetes API; release state lives in Secrets in the namespace. RBAC is now your normal kubectl RBAC. Helm 3 also moved from JSON schema to OpenAPI v3, switched from gRPC to REST, and stopped requiring 'helm init'.
What's the difference between Helm and Kustomize?
Helm uses Go templates with sprig functions to generate YAML — you have full programmability (loops, conditionals, function calls) but the templates are not valid YAML until rendered. Kustomize is overlay-based: a base YAML and patches/overlays applied to it, no templating. Kustomize is built into kubectl (kubectl apply -k), simpler for single-cluster customization, but limited for complex parameterization. Helm is better for distributing reusable charts across organizations; Kustomize is better for environment-specific overlays of your own apps.
How do hooks work?
Hooks are templates with a special annotation — helm.sh/hook: pre-install, post-install, pre-upgrade, post-upgrade, pre-delete, etc. Helm applies them at the named lifecycle point and waits for them to complete (Pods to succeed, Jobs to finish). Common uses: a pre-install Job that creates a database schema; a post-upgrade Job that runs migrations; a pre-delete Job that backs up data. Hooks are not part of the release the way regular templates are — they're separate objects with their own lifecycle and aren't deleted on helm uninstall (unless you use the helm.sh/hook-delete-policy annotation).
What does Helm store the release in?
By default, Helm 3 stores release state in a Kubernetes Secret in the same namespace as the release, named sh.helm.release.v1.<release-name>.<revision>. Each helm install/upgrade creates a new Secret. helm rollback finds the right Secret and re-applies its contents. You can change the storage backend (Secret, ConfigMap, SQL postgres) via HELM_DRIVER env var. Secrets are the default because they get the encryption-at-rest treatment that ConfigMaps don't (when etcd encryption is enabled).
OCI registries vs traditional Helm repos?
Originally Helm charts lived in a HTTP server with an index.yaml — the 'chart museum' model. As of Helm 3.8, charts can also be stored as OCI artifacts in any container registry (Docker Hub, GHCR, ECR, GCR, Harbor). One registry holds your images and your charts. Push: helm push mychart-1.0.0.tgz oci://ghcr.io/myorg/charts. Pull: helm install x oci://ghcr.io/myorg/charts/mychart --version 1.0.0. The traditional repo model still works and is sometimes preferred for public charts indexed by ArtifactHub.
When to use cdk8s instead?
cdk8s lets you write Kubernetes manifests in TypeScript, Python, Java, or Go using a real programming language with type checking, refactoring, and reusable libraries. It's appealing when YAML templating gets unwieldy or when you want to share constructs across many apps. Drawbacks: another tool layer, the output is YAML so debugging diff issues still happens at the YAML level, and the ecosystem of pre-built constructs is smaller than Helm's chart catalog. Pick cdk8s if your team is already invested in CDK-style infrastructure-as-code.
How does the 3-way strategic merge patch work in Helm upgrades?
When you run helm upgrade, Helm compares the previous release manifest, the live cluster state, and the new desired manifest. It computes a 3-way diff: it knows what was in the previous release, what's actually running in the cluster, and what you're deploying now. If someone manually edited a resource (which is discouraged but happens), Helm tries to preserve those changes. For most cases it just applies the delta between old and new. The 'last-applied-configuration' annotation that kubectl add/set replaces is NOT used by Helm — Helm tracks state differently through its own release Secrets.
Why are subchart values overrides so surprising?
In Helm, parent chart values don't automatically cascade to subcharts unless the subchart explicitly references them. If your parent chart sets `image.repository: myorg/myapp` and the subchart also has an `image.repository` key, they don't merge — the subchart's value wins if you don't explicitly pass it down. This trips up many chart authors. The solution: in the parent values.yaml, define subchart values under the subchart's name key, and the subchart reads from its own namespace. Charts that follow the 'flat values' pattern (putting everything at the top level) work differently than charts that use namespaced values.
What is a library chart and when should I use one?
A library chart (type: library in Chart.yaml) is a chart that provides only templates — no Kubernetes resources, no values.yaml defaults. It's a shared utilities bucket: common labels, annotations, helpers, configmap templates. Other charts depend on it and can include its named templates. The Bitnami common chart is the canonical example. Use library charts when you have multiple charts that share the same _helpers.tpl content and you want DRY. Note: library chart templates are merged into the consuming chart's template compilation, so they can't have their own `.Values` access by default — they operate on the parent chart's scope.
How do CRDs in the crds/ directory differ from templates?
CRDs placed in the chart's crds/ directory are installed before any templates run, and they are not templated — they're plain YAML files. This is critical because CRDs must be valid Kubernetes resources to register the type. Helm will refuse to install a chart if the CRD would fail validation (unless you use --skip-crds). The crds/ directory is for custom resource definitions your chart introduces. Templates directory, meanwhile, is for your actual workload manifests. If you need to template a CRD (e.g., different group/version per environment), you can't put it in crds/; instead, manage it as a regular template but understand it won't be validated at install time.