← Back to Blog

Helm Charts Tutorial: Deploy Kubernetes Apps Like a Pro (2026)

Helm is the package manager for Kubernetes. Instead of managing dozens of raw YAML manifests, Helm lets you define, install, upgrade, and rollback applications as versioned packages called charts. This tutorial covers everything from installation to writing your own charts and managing production releases.

The Problem Helm Solves

Deploying an application to Kubernetes requires multiple YAML files: a Deployment, a Service, a ConfigMap, an Ingress, possibly a ServiceAccount and RBAC rules, and more. When you have multiple environments (dev, staging, production), you end up duplicating these files with minor differences in image tags, replica counts, and resource limits.

Without Helm, teams typically resort to environment-specific directories with mostly duplicate YAML, or brittle shell scripts that use sed to substitute values. Neither approach scales or versions well.

Helm introduces three core concepts: Charts (packages of Kubernetes manifests with templating), Values (configuration injected into templates at install time), and Releases (a named instance of a chart deployed to a cluster, with full version history).

Installing Helm

# macOS
brew install helm

# Linux (script)
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

# Windows (Chocolatey)
choco install kubernetes-helm

# Verify
helm version

Helm 3 is the current version. Helm 2 required a cluster-side component called Tiller, which caused security concerns. Helm 3 is tiller-free and uses your existing kubeconfig for cluster access.

Core Helm Commands

# Add the Bitnami chart repository
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

# Search for charts
helm search repo nginx
helm search hub wordpress

# Install a chart (creates a release named "my-nginx")
helm install my-nginx bitnami/nginx

# Install with custom values
helm install my-nginx bitnami/nginx --set service.type=ClusterIP

# Install with a values file
helm install my-nginx bitnami/nginx -f values-prod.yaml

# List installed releases
helm list
helm list --all-namespaces

# Get release status
helm status my-nginx

# Inspect what a chart will render (dry run)
helm install my-nginx bitnami/nginx --dry-run --debug

# Render templates locally without installing
helm template my-nginx bitnami/nginx -f values-prod.yaml

# Upgrade a release
helm upgrade my-nginx bitnami/nginx --set image.tag=1.25

# Upgrade and install if not present (--install flag)
helm upgrade --install my-nginx bitnami/nginx -f values.yaml

# Rollback to a previous revision
helm rollback my-nginx 1

# View release history
helm history my-nginx

# Uninstall a release
helm uninstall my-nginx

Chart Structure

Create a new chart scaffold with helm create myapp. This generates the standard directory layout:

myapp/
  Chart.yaml          # Chart metadata (name, version, description)
  values.yaml         # Default values for templates
  charts/             # Dependencies (sub-charts)
  templates/          # Kubernetes manifest templates
    deployment.yaml
    service.yaml
    ingress.yaml
    serviceaccount.yaml
    _helpers.tpl      # Template helpers and partials
    NOTES.txt         # Post-install usage notes shown to the user
  .helmignore         # Files to exclude when packaging

Chart.yaml

apiVersion: v2
name: myapp
description: My application Helm chart
type: application
version: 0.1.0       # Chart version (semver)
appVersion: "1.0.0"  # Application version (informational)

# Optional: list chart dependencies
dependencies:
  - name: postgresql
    version: "12.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled

Writing Templates

Templates are Kubernetes YAML files with Go template syntax for dynamic values. The {{ .Values.key }} syntax injects values from values.yaml or user-supplied overrides.

# 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:
    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:
            - containerPort: {{ .Values.service.port }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          {{- if .Values.env }}
          env:
            {{- range $key, $value := .Values.env }}
            - name: {{ $key }}
              value: {{ $value | quote }}
            {{- end }}
          {{- end }}

Values Files

The values.yaml file defines defaults. Users override values at install time with -f or --set. A typical setup uses a base values.yaml and environment-specific override files:

# values.yaml (defaults)
replicaCount: 1

image:
  repository: myregistry/myapp
  tag: ""
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: false
  className: nginx
  hostname: app.example.com

resources:
  limits:
    cpu: 500m
    memory: 256Mi
  requests:
    cpu: 100m
    memory: 128Mi

postgresql:
  enabled: true
  auth:
    username: app
    database: appdb
# values-production.yaml (production overrides)
replicaCount: 3

image:
  tag: "2.1.0"

ingress:
  enabled: true

resources:
  limits:
    cpu: 2000m
    memory: 1Gi
  requests:
    cpu: 500m
    memory: 512Mi

Deploy to production:

helm upgrade --install myapp ./myapp \
  -f values.yaml \
  -f values-production.yaml \
  --namespace production \
  --create-namespace

Scan Your Site for Free

Our Exposure Checker runs 19 parallel security checks - SSL, headers, exposed paths, DNS, open ports, and more.

Run Free Security Scan

Template Helpers and _helpers.tpl

The _helpers.tpl file defines reusable named templates. The leading underscore tells Helm not to render it as a Kubernetes manifest. Helpers avoid repeating logic like label generation across all templates:

{{/*
Expand the name of the chart.
*/}}
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels applied to all resources
*/}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
{{ include "myapp.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels (used in matchLabels and podTemplate labels)
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Helm Hooks

Hooks run Kubernetes jobs at specific points in the release lifecycle: before install, after install, before upgrade, after upgrade, before delete, etc. Common uses are database migrations, cache warming, and pre-flight checks.

# templates/db-migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "myapp.fullname" . }}-migrate
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "-5"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["./migrate", "up"]
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: {{ include "myapp.fullname" . }}-db
                  key: url

The hook-delete-policy: before-hook-creation,hook-succeeded annotation cleans up the job after it succeeds, keeping the namespace tidy.

Managing Dependencies

Charts can declare dependencies on other charts in Chart.yaml. This is how you bundle a PostgreSQL or Redis sub-chart alongside your application.

# Download dependencies defined in Chart.yaml
helm dependency update ./myapp

# This creates/updates charts/postgresql-12.x.x.tgz

# Install with dependencies
helm install myapp ./myapp

# Override a sub-chart value using dot notation
helm install myapp ./myapp --set postgresql.auth.password=securepassword

Debugging and Linting

# Lint a chart for common issues
helm lint ./myapp

# Lint with production values
helm lint ./myapp -f values-production.yaml

# Render all templates and print to stdout (without installing)
helm template myapp ./myapp -f values-production.yaml

# Render a single template
helm template myapp ./myapp -f values-production.yaml -s templates/deployment.yaml

# Install with debug output and dry-run
helm install myapp ./myapp --dry-run --debug 2>&1 | less

# Get the manifest of an installed release
helm get manifest myapp

# Get the values used for an installed release
helm get values myapp
helm get values myapp --all  # includes chart defaults

Scan Your Site for Free

Our Exposure Checker runs 19 parallel security checks - SSL, headers, exposed paths, DNS, open ports, and more.

Run Free Security Scan

Frequently Asked Questions

What is the difference between helm install and helm upgrade --install?

helm install always creates a new release and fails if a release with that name already exists. helm upgrade --install installs the release if it does not exist, or upgrades it if it does. In CI/CD pipelines, always use helm upgrade --install so your pipeline is idempotent regardless of whether the application is being deployed for the first time or updated.

How do I pass secrets to Helm without storing them in values files?

Never put secrets in values.yaml or pass them via --set password=secret (they appear in shell history). Use one of these approaches: Kubernetes Secrets pre-created outside Helm and referenced by name in templates; External Secrets Operator pulling from AWS Secrets Manager or Vault; or the helm-secrets plugin that encrypts values files with SOPS. The cleanest pattern for production is ExternalSecrets - Helm manages the application structure, and the secrets management system manages the secrets.

What is the .Release object in Helm templates?

The .Release object contains metadata about the current release: .Release.Name (the release name passed to helm install), .Release.Namespace, .Release.IsInstall (true on first install), .Release.IsUpgrade, and .Release.Revision (revision number, starts at 1). Use .Release.Name in resource names to support multiple releases of the same chart in the same namespace.

How does Helm handle upgrades that fail?

By default, Helm marks a failed upgrade as FAILED and does not automatically roll back. Use --atomic to automatically roll back to the previous revision if the upgrade fails (waits for pods to become ready within the timeout). Use --timeout 5m0s to set how long to wait. For manual rollback: helm rollback myapp rolls back to the previous revision, or helm rollback myapp 3 rolls back to a specific revision number from helm history myapp.

What is the difference between chart version and appVersion in Chart.yaml?

The version field is the version of the Helm chart itself (the packaging). The appVersion field is the version of the application the chart deploys. They are independent. You might bump the chart version when you add a new template option without changing the application version. Most chart maintainers use .Chart.AppVersion as the default image tag in templates, so setting image.tag in values overrides it for custom builds.

Summary

Helm transforms Kubernetes deployments from manual YAML management into repeatable, versioned package releases. The core workflow is: write a chart with templates and a values file, test it with helm lint and helm template, deploy with helm upgrade --install, and roll back with helm rollback if something goes wrong.

For YAML syntax errors in your templates, use our YAML Validator to catch issues before they reach the cluster. Well-structured Helm charts with good default values and clear upgrade paths are the foundation of reliable Kubernetes deployments.

Validate your Helm YAML output instantly → Use our free YAML Validator here

UK
Written by Usman Khan
DevOps Engineer | MSc Cybersecurity | CEH | AWS Solutions Architect

Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.