Helm Charts: The Complete Guide for 2026
Kubernetes manifests are verbose. A simple web application needs a Deployment, a Service, a ConfigMap, maybe an Ingress and a HorizontalPodAutoscaler — each in its own YAML file with duplicated labels, selectors, and configuration values. Multiply that by three environments (dev, staging, production) and you are managing dozens of nearly identical files where a single typo causes a production outage. Helm solves this by turning Kubernetes manifests into parameterized, reusable, versioned packages called charts.
This guide covers Helm from installation through production deployment, including chart structure, Go templates, dependency management, hooks, testing, Helmfile, and best practices. If you are new to containers, start with our Docker Compose guide first.
Table of Contents
- What is Helm
- Helm vs Raw Kubernetes YAML
- Installing Helm
- Chart Structure
- Creating Your First Chart
- Templates and Values
- Built-in Objects
- Template Functions
- Control Flow: if/else, range, with
- Chart Dependencies
- Hooks
- Testing Charts
- Deploying to Production
- Chart Repositories
- Helmfile
- Best Practices
- Frequently Asked Questions
1. What is Helm
Helm is the package manager for Kubernetes. It packages Kubernetes resource definitions into charts — versioned bundles of templated YAML files plus metadata. You install a chart into your cluster as a release, and Helm tracks every revision so you can upgrade, roll back, and delete cleanly.
The core concepts:
- Chart — a package containing templated Kubernetes manifests, default values, and metadata
- Release — an installed instance of a chart in a cluster (you can install the same chart multiple times with different release names)
- Repository — a server hosting packaged charts (like Docker Hub for container images)
- Values — configuration parameters that customize a chart for a specific environment
- Revision — a version of a release; every
helm upgradecreates a new revision
2. Helm vs Raw Kubernetes YAML
# Raw YAML: hardcoded values, duplicated across environments
# deployment-prod.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
labels:
app: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
spec:
containers:
- name: myapp
image: myapp:v2.1.0
resources:
limits:
memory: "512Mi"
# You need a separate file for staging with replicas: 1, memory: 256Mi, etc.
# Helm template: parameterized, one chart serves all environments
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
resources:
{{- toYaml .Values.resources | nindent 12 }}
# Deploy to production: helm install myapp ./chart -f values-prod.yaml
# Deploy to staging: helm install myapp ./chart -f values-staging.yaml
Why Helm wins: one chart, multiple environments, version-controlled values, rollback support, dependency management, and a massive ecosystem of community charts.
3. Installing Helm
# macOS (Homebrew)
brew install helm
# Linux (official install script)
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Windows (Chocolatey)
choco install kubernetes-helm
# Verify installation
helm version
# version.BuildInfo{Version:"v3.16.x", ...}
# Add the official stable charts repository
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
Helm 3 is the current major version. It removed the server-side component (Tiller) that Helm 2 required, making it simpler and more secure. Helm 3 stores release state as Kubernetes secrets in the release namespace.
4. Chart Structure
mychart/
Chart.yaml # Chart metadata: name, version, description, dependencies
Chart.lock # Locked dependency versions (auto-generated)
values.yaml # Default configuration values
values.schema.json # Optional: JSON Schema for validating values
templates/ # Kubernetes manifest templates
deployment.yaml
service.yaml
ingress.yaml
hpa.yaml
configmap.yaml
_helpers.tpl # Template helper functions (partials)
NOTES.txt # Post-install usage instructions
tests/ # Helm test definitions
test-connection.yaml
charts/ # Dependency charts (downloaded .tgz files)
.helmignore # Files to exclude from packaging
Chart.yaml
apiVersion: v2 # v2 for Helm 3 charts
name: myapp
description: A web application chart
type: application # application or library
version: 1.2.0 # chart version (SemVer)
appVersion: "2.1.0" # version of the app being deployed
keywords:
- web
- api
maintainers:
- name: DevTeam
email: team@example.com
dependencies:
- name: postgresql
version: "~15.0"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
values.yaml
# Default values for myapp
replicaCount: 1
image:
repository: myapp
tag: "latest"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: nginx
hosts:
- host: myapp.local
paths:
- path: /
pathType: Prefix
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilization: 80
postgresql:
enabled: true
auth:
database: myapp
username: myapp
5. Creating Your First Chart
# Scaffold a new chart with default templates
helm create myapp
# This generates:
# myapp/Chart.yaml
# myapp/values.yaml
# myapp/templates/deployment.yaml
# myapp/templates/service.yaml
# myapp/templates/ingress.yaml
# myapp/templates/hpa.yaml
# myapp/templates/serviceaccount.yaml
# myapp/templates/_helpers.tpl
# myapp/templates/NOTES.txt
# myapp/templates/tests/test-connection.yaml
# myapp/.helmignore
# Preview the rendered manifests without installing
helm template myapp ./myapp
# Preview with custom values
helm template myapp ./myapp --set replicaCount=3 --set image.tag=v2.0
# Install the chart into your cluster
helm install myapp ./myapp
# Install into a specific namespace
helm install myapp ./myapp --namespace production --create-namespace
6. Templates and Values
Helm templates use Go's text/template syntax. Anything inside {{ }} is evaluated at render time. The most common pattern is accessing values from values.yaml via the .Values object.
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
# templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "myapp.selectorLabels" . | nindent 4 }}
Whitespace Control
The - inside template delimiters trims whitespace. This is critical for producing valid YAML:
# {{- trims whitespace before the tag
# -}} trims whitespace after the tag
metadata:
labels:
{{- include "myapp.labels" . | nindent 4 }}
# ^ without the -, you get a blank line above the labels
# nindent N adds a newline + N spaces of indentation
# indent N adds N spaces without a leading newline
7. Built-in Objects
Every template has access to these top-level objects:
# .Values - values from values.yaml + overrides
{{ .Values.image.repository }}
# .Chart - contents of Chart.yaml
{{ .Chart.Name }}
{{ .Chart.Version }}
{{ .Chart.AppVersion }}
# .Release - metadata about the current release
{{ .Release.Name }} # release name (helm install NAME ...)
{{ .Release.Namespace }} # target namespace
{{ .Release.Revision }} # revision number (increments on upgrade)
{{ .Release.IsUpgrade }} # true if this is an upgrade
{{ .Release.IsInstall }} # true if this is a fresh install
# .Template - info about the current template file
{{ .Template.Name }} # e.g., myapp/templates/deployment.yaml
{{ .Template.BasePath }} # e.g., myapp/templates
# .Capabilities - cluster capabilities
{{ .Capabilities.KubeVersion }}
{{ .Capabilities.APIVersions.Has "apps/v1" }}
8. Template Functions
include
Calls a named template (defined in _helpers.tpl) and returns the result as a string that can be piped:
# _helpers.tpl
{{- define "myapp.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
# Usage in templates:
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
tpl
Renders a string as a template. Useful when values.yaml contains template expressions:
# values.yaml
configMap:
dbHost: "{{ .Release.Name }}-postgresql"
# templates/configmap.yaml
data:
DB_HOST: {{ tpl .Values.configMap.dbHost . }}
required
Fails rendering if a value is not provided:
# Fail with a clear message if image.tag is not set
image: "{{ .Values.image.repository }}:{{ required "image.tag is required" .Values.image.tag }}"
Common Utility Functions
# String functions
{{ .Values.name | upper }} # MYAPP
{{ .Values.name | lower }} # myapp
{{ .Values.name | title }} # Myapp
{{ .Values.name | quote }} # "myapp"
{{ .Values.name | trunc 63 }} # truncate to 63 chars
{{ .Values.name | trimSuffix "-" }} # remove trailing dash
# Default values
{{ .Values.image.tag | default "latest" }}
# Type conversion
{{ .Values.port | int }}
{{ .Values.debug | toString }}
# YAML/JSON output
{{ toYaml .Values.resources | nindent 12 }}
{{ toJson .Values.config }}
# Encoding
{{ .Values.secret | b64enc }} # base64 encode
{{ .Values.data | b64dec }} # base64 decode
# Lookup (query live cluster resources)
{{ lookup "v1" "Secret" "default" "my-secret" }}
9. Control Flow: if/else, range, with
if/else
# Conditionally include resources
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" . }}
{{- if .Values.ingress.annotations }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "myapp.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
range (loops)
# Iterate over a list
env:
{{- range .Values.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
# Iterate over a map
data:
{{- range $key, $value := .Values.configData }}
{{ $key }}: {{ $value | quote }}
{{- end }}
# values.yaml
env:
- name: NODE_ENV
value: production
- name: LOG_LEVEL
value: info
configData:
DB_HOST: postgres
DB_PORT: "5432"
with (scope change)
# Change the scope of . to a sub-object
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
# Inside a with block, . refers to .Values.nodeSelector
# Use $ to access the root scope:
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 10 }}
# $.Release.Name still works here
{{- end }}
10. Chart Dependencies
Charts can depend on other charts. Declare dependencies in Chart.yaml and Helm downloads them into the charts/ directory.
# Chart.yaml
dependencies:
- name: postgresql
version: "~15.5" # SemVer range: >=15.5.0, <16.0.0
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled # only include if this value is true
- name: redis
version: "~19.0"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled
- name: common
version: "2.x.x"
repository: "https://charts.bitnami.com/bitnami"
tags:
- infrastructure # enable/disable via tags
# Download and lock dependencies
helm dependency update ./mychart
# This creates:
# charts/postgresql-15.5.x.tgz
# charts/redis-19.0.x.tgz
# Chart.lock (locked versions)
# Override dependency values from parent chart
# values.yaml
postgresql:
enabled: true
auth:
database: myapp
username: myapp
password: secretpassword
primary:
resources:
limits:
memory: 1Gi
redis:
enabled: false # disable Redis dependency
11. Hooks
Helm hooks are templates annotated to run at specific points in the release lifecycle. Common uses include database migrations, backups before upgrades, and cleanup jobs.
# templates/pre-upgrade-migration.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-migrate
annotations:
"helm.sh/hook": pre-upgrade # run before upgrade
"helm.sh/hook-weight": "0" # ordering (lower runs first)
"helm.sh/hook-delete-policy": hook-succeeded # clean up on success
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["./migrate", "--up"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "myapp.fullname" . }}-db
key: url
Available hook events:
pre-install/post-install— before/after first installpre-upgrade/post-upgrade— before/after upgradepre-delete/post-delete— before/after release deletionpre-rollback/post-rollback— before/after rollbacktest— when runninghelm test
Delete policies control cleanup: hook-succeeded, hook-failed, or before-hook-creation (deletes old hook resource before creating a new one).
12. Testing Charts
helm lint
# Validate chart structure and templates
helm lint ./mychart
# ==> Linting ./mychart
# [INFO] Chart.yaml: icon is recommended
# 1 chart(s) linted, 0 chart(s) failed
# Lint with specific values
helm lint ./mychart -f values-prod.yaml
helm template (dry run)
# Render templates locally without a cluster
helm template myapp ./mychart -f values-prod.yaml
# Pipe to kubectl for server-side validation
helm template myapp ./mychart | kubectl apply --dry-run=server -f -
helm test
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "myapp.fullname" . }}-test"
annotations:
"helm.sh/hook": test
spec:
restartPolicy: Never
containers:
- name: test
image: busybox
command: ['wget', '--spider', 'http://{{ include "myapp.fullname" . }}:{{ .Values.service.port }}/health']
# Run tests against a deployed release
helm test myapp
# Pod myapp-test pending
# Pod myapp-test running
# Pod myapp-test succeeded
# NAME: myapp
# STATUS: deployed
# TEST SUITE: myapp-test - Succeeded
Automated Testing with ct (chart-testing)
# Install chart-testing CLI
# https://github.com/helm/chart-testing
ct lint --charts ./mychart
ct install --charts ./mychart
# In CI (GitHub Actions)
# - uses: helm/chart-testing-action@v2
# with:
# command: lint-and-install
13. Deploying to Production
# First install
helm install myapp ./mychart \
--namespace production \
--create-namespace \
-f values-prod.yaml \
--wait \
--timeout 5m
# Upgrade an existing release
helm upgrade myapp ./mychart \
--namespace production \
-f values-prod.yaml \
--wait \
--timeout 5m
# Install or upgrade (idempotent, perfect for CI/CD)
helm upgrade --install myapp ./mychart \
--namespace production \
--create-namespace \
-f values-prod.yaml \
--wait \
--timeout 5m \
--atomic # auto-rollback on failure
# Check release status
helm status myapp -n production
# View release history
helm history myapp -n production
# REVISION STATUS DESCRIPTION
# 1 superseded Install complete
# 2 superseded Upgrade complete
# 3 deployed Upgrade complete
# Roll back to a previous revision
helm rollback myapp 2 -n production
# Uninstall a release
helm uninstall myapp -n production
14. Chart Repositories
# Add a repository
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
# Update repository index
helm repo update
# Search for charts
helm search repo postgresql
helm search repo nginx --versions # show all available versions
# Search Artifact Hub (public chart registry)
helm search hub wordpress
# Install from a repository
helm install my-pg bitnami/postgresql \
--set auth.postgresPassword=secret \
--namespace databases \
--create-namespace
# Package your chart for distribution
helm package ./mychart
# mychart-1.2.0.tgz
# Host charts with OCI registries (Helm 3.8+)
helm push mychart-1.2.0.tgz oci://registry.example.com/charts
helm install myapp oci://registry.example.com/charts/mychart --version 1.2.0
15. Helmfile
Helmfile manages multiple Helm releases declaratively. Instead of running helm install and helm upgrade commands manually, you define all releases in a helmfile.yaml and apply the desired state.
# helmfile.yaml
repositories:
- name: bitnami
url: https://charts.bitnami.com/bitnami
- name: ingress-nginx
url: https://kubernetes.github.io/ingress-nginx
environments:
dev:
values:
- env/dev.yaml
production:
values:
- env/production.yaml
releases:
- name: ingress
namespace: ingress-system
chart: ingress-nginx/ingress-nginx
version: 4.10.x
values:
- values/ingress.yaml
- name: api
namespace: {{ .Environment.Name }}
chart: ./charts/api
values:
- values/api-common.yaml
- values/api-{{ .Environment.Name }}.yaml
set:
- name: image.tag
value: {{ requiredEnv "APP_VERSION" }}
- name: database
namespace: {{ .Environment.Name }}
chart: bitnami/postgresql
version: 15.5.x
values:
- values/postgresql-{{ .Environment.Name }}.yaml
needs:
- ingress-system/ingress # dependency ordering
# Preview changes before applying
helmfile -e production diff
# Apply all releases
helmfile -e production apply
# Sync a specific release
helmfile -e production -l name=api apply
# Destroy all releases
helmfile -e production destroy
16. Best Practices
Chart Design
- Use
_helpers.tplfor all label and name generation — never hardcode names in templates - Follow Kubernetes label conventions —
app.kubernetes.io/name,app.kubernetes.io/instance,app.kubernetes.io/version - Provide sensible defaults — the chart should install with just
helm install myapp ./chartand no extra flags - Document every value — add comments in
values.yamlexplaining each parameter - Add a values.schema.json — validates user-provided values at install/upgrade time
Production Deployments
# Always use --wait and --timeout
helm upgrade --install myapp ./chart \
--wait --timeout 5m
# Use --atomic for automatic rollback on failure
helm upgrade --install myapp ./chart \
--atomic --timeout 5m
# Pin chart versions in CI/CD (never use latest)
helm install myapp bitnami/postgresql --version 15.5.28
# Use namespaces to isolate environments
helm upgrade --install myapp ./chart \
--namespace production \
--create-namespace
Security
- Never put secrets in values.yaml — use Kubernetes Secrets, external secret managers (Vault, AWS Secrets Manager), or the External Secrets Operator
- Set resource requests and limits — prevent noisy-neighbor problems and OOM kills
- Run containers as non-root — set
securityContext.runAsNonRoot: truein your templates - Use read-only root filesystem —
securityContext.readOnlyRootFilesystem: truewhere possible
Version Management
# Chart.yaml versioning
# version: chart packaging version (bump on template/value changes)
# appVersion: the application version being deployed
# Semantic versioning for charts:
# MAJOR: breaking changes to values.yaml schema
# MINOR: new features, backward-compatible
# PATCH: bug fixes, documentation
# Use --set image.tag in CI/CD instead of rebuilding the chart
helm upgrade --install myapp ./chart \
--set image.tag=$GIT_SHA
Frequently Asked Questions
What is the difference between Helm and raw Kubernetes YAML?
Raw Kubernetes YAML requires you to duplicate manifests for every environment and manually update values across multiple files. Helm packages manifests into reusable charts with Go templates and a values.yaml file, letting you parameterize deployments, manage releases with rollback support, and share configurations as versioned artifacts. Helm also handles dependency management between charts, provides lifecycle hooks for migrations, and integrates with CI/CD pipelines. For anything beyond a single static deployment, Helm dramatically reduces configuration drift and human error.
How do I pass custom values to a Helm chart?
Helm supports several methods for overriding default values. You can use the --set flag for individual values (helm install myapp ./chart --set replicaCount=3), use --values or -f to pass a custom values file (helm install myapp ./chart -f production-values.yaml), or combine both approaches where --set takes highest priority. Multiple -f flags can be stacked, with later files overriding earlier ones. For complex values like arrays or nested objects, use --set-json or a values file. Environment-specific values files (values-dev.yaml, values-prod.yaml) are the recommended pattern for managing multiple environments.
What is Helmfile and when should I use it?
Helmfile is a declarative tool for managing multiple Helm releases across environments. While Helm manages individual chart installations, Helmfile manages the complete desired state of all your Helm releases in a single helmfile.yaml. It supports environment-specific values, release dependencies, hooks, and diff previews before applying changes. Use Helmfile when you manage more than a few Helm releases, need reproducible multi-chart deployments, or want to version-control your entire cluster configuration. It is particularly valuable in GitOps workflows where you want a single file describing every release in a cluster.
How do Helm chart dependencies work?
Helm chart dependencies are declared in Chart.yaml under the dependencies key. Each dependency specifies a chart name, version range, and repository URL. Running helm dependency update downloads the dependency charts into the charts/ directory as .tgz archives. Dependencies can be conditionally enabled using the condition or tags fields, and their values can be overridden from the parent chart's values.yaml. This lets you compose complex applications from reusable components, for example adding PostgreSQL, Redis, or Prometheus as sub-charts without maintaining their templates yourself.
How do I roll back a failed Helm deployment?
Helm maintains a history of every release revision. To roll back, use helm rollback <release-name> <revision-number>. Run helm history <release-name> to see all revisions with their status and description. Helm stores release metadata as Kubernetes secrets by default, so the history persists across CLI sessions. You can also set --atomic on helm upgrade to automatically roll back if the deployment fails health checks within the --timeout period. For production, always use --wait to ensure pods are actually running before Helm marks the release as successful.
Conclusion
Helm transforms Kubernetes YAML management from a manual, error-prone process into a structured workflow with versioning, parameterization, and rollback. Whether you are deploying a single microservice or an entire platform with dozens of interdependent services, Helm charts give you reproducible, auditable deployments across every environment.
Start with helm create to scaffold your first chart, use helm template to iterate on templates locally, and deploy with helm upgrade --install --atomic for safe, idempotent releases. As your infrastructure grows, adopt Helmfile to manage multiple releases declaratively and integrate everything into your CI/CD pipeline.
Learn More
- Docker Compose: The Complete Guide — orchestrate containers locally before deploying to Kubernetes
- Docker: The Complete Guide — master containers, images, Dockerfiles, and Docker fundamentals
- Terraform: The Complete Guide — provision the infrastructure your Kubernetes clusters run on
- GitHub Actions CI/CD: The Complete Guide — automate Helm deployments in your CI/CD pipeline
- YAML: The Complete Guide — understand the format that powers Helm charts and Kubernetes manifests
- Dockerfile Builder — generate Dockerfiles for the container images your charts deploy