Helm Charts: The Complete Guide for 2026

Published February 12, 2026 · 30 min read

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.

⚙ Related: Validate your chart YAML with the YAML Validator and build container images with the Dockerfile Builder.

Table of Contents

  1. What is Helm
  2. Helm vs Raw Kubernetes YAML
  3. Installing Helm
  4. Chart Structure
  5. Creating Your First Chart
  6. Templates and Values
  7. Built-in Objects
  8. Template Functions
  9. Control Flow: if/else, range, with
  10. Chart Dependencies
  11. Hooks
  12. Testing Charts
  13. Deploying to Production
  14. Chart Repositories
  15. Helmfile
  16. Best Practices
  17. 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:

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:

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
⚙ Related: Containerize your app first with our Dockerfile Builder and orchestrate locally with Docker Compose.

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

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

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

Related Resources

Docker Compose: The Complete Guide
Multi-container orchestration with services, networks, and volumes
Terraform: The Complete Guide
Infrastructure as code for provisioning cloud resources and clusters
YAML: The Complete Guide
Master the data format that powers Helm charts and Kubernetes
Dockerfile Builder
Generate Dockerfiles for the images your Helm charts deploy