Kubernetes YAML gets repetitive fast. Helm is the package manager that lets you template, version, and share Kubernetes manifests. Here’s everything you need to go from zero to managing production applications with Helm.

Why Helm?

Without Helm:

  • Copy-paste YAML between environments
  • Manual search-and-replace for configuration
  • No versioning of deployments
  • No easy rollback

With Helm:

  • Templated manifests with values
  • Versioned releases with history
  • One-command rollbacks
  • Reusable packages (charts)

Installing Helm

# macOS
brew install helm

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

# Windows (chocolatey)
choco install kubernetes-helm

# Verify
helm version

Helm Concepts

ConceptDescription
ChartPackage of Kubernetes manifests
ReleaseInstalled instance of a chart
RepositoryCollection of charts
ValuesConfiguration for a chart

Using Existing Charts

Add Repositories

# Add Bitnami repo (tons of production-ready charts)
helm repo add bitnami https://charts.bitnami.com/bitnami

# Add Prometheus community charts
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts

# Update repos
helm repo update

# Search for charts
helm search repo nginx
helm search repo postgresql

Install a Chart

# Install with default values
helm install my-nginx bitnami/nginx

# Install with custom values
helm install my-nginx bitnami/nginx \
  --set replicaCount=3 \
  --set service.type=LoadBalancer

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

# Install to specific namespace
helm install my-nginx bitnami/nginx -n production --create-namespace

# Install specific version
helm install my-nginx bitnami/nginx --version 15.0.0

Manage Releases

# List releases
helm list
helm list -n production
helm list --all-namespaces

# Get release info
helm status my-nginx
helm get values my-nginx
helm get manifest my-nginx

# Upgrade release
helm upgrade my-nginx bitnami/nginx --set replicaCount=5

# Rollback
helm rollback my-nginx 1  # Rollback to revision 1
helm history my-nginx     # View revision history

# Uninstall
helm uninstall my-nginx

Creating Your Own Chart

Chart Structure

# Create new chart
helm create my-app

# Structure
my-app/
├── Chart.yaml          # Chart metadata
├── values.yaml         # Default configuration
├── charts/             # Dependencies
├── templates/          # Kubernetes manifests
   ├── deployment.yaml
   ├── service.yaml
   ├── ingress.yaml
   ├── hpa.yaml
   ├── _helpers.tpl    # Template helpers
   ├── NOTES.txt       # Post-install notes
   └── tests/
       └── test-connection.yaml
└── .helmignore         # Files to ignore

Chart.yaml

# Chart.yaml
apiVersion: v2
name: my-app
description: A Helm chart for my application
type: application
version: 0.1.0        # Chart version
appVersion: "1.0.0"   # Application version

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

# Keywords for search
keywords:
  - web
  - application

maintainers:
  - name: DevOps Team
    email: [email protected]

values.yaml

# values.yaml - Default configuration
replicaCount: 1

image:
  repository: my-app
  tag: "latest"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: false
  className: nginx
  hosts:
    - host: my-app.local
      paths:
        - path: /
          pathType: Prefix
  tls: []

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

autoscaling:
  enabled: false
  minReplicas: 1
  maxReplicas: 10
  targetCPUUtilizationPercentage: 80

env: {}

secrets: {}

postgresql:
  enabled: false
  auth:
    postgresPassword: ""
    database: app

Template: deployment.yaml

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ include "my-app.serviceAccountName" . }}
      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.env }}
          env:
            {{- range $key, $value := . }}
            - name: {{ $key }}
              value: {{ $value | quote }}
            {{- end }}
          {{- end }}
          {{- if .Values.secrets }}
          envFrom:
            - secretRef:
                name: {{ include "my-app.fullname" . }}-secrets
          {{- end }}
          livenessProbe:
            httpGet:
              path: /health
              port: http
            initialDelaySeconds: 30
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            initialDelaySeconds: 5
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Template Helpers: _helpers.tpl

# templates/_helpers.tpl
{{/*
Expand the name of the chart.
*/}}
{{- define "my-app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
*/}}
{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

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

{{/*
Selector labels
*/}}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Create the name of the service account to use
*/}}
{{- define "my-app.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "my-app.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

Template Syntax

Variables and Pipelines

# Basic variable
{{ .Values.replicaCount }}

# Pipeline (pass value through functions)
{{ .Values.image.tag | default "latest" | quote }}

# Conditional
{{- if .Values.ingress.enabled }}
# ingress manifest here
{{- end }}

# Range (loops)
{{- range .Values.ingress.hosts }}
  - host: {{ .host | quote }}
{{- end }}

# With (scope)
{{- with .Values.resources }}
resources:
  {{- toYaml . | nindent 2 }}
{{- end }}

Common Functions

# Default value
{{ .Values.tag | default "latest" }}

# Quote strings
{{ .Values.name | quote }}

# Indent/nindent
{{ toYaml .Values.resources | nindent 4 }}

# Required (fail if missing)
{{ required "image.repository is required" .Values.image.repository }}

# Lookup (get existing K8s resources)
{{ lookup "v1" "Secret" .Release.Namespace "my-secret" }}

# tpl (render string as template)
{{ tpl .Values.configTemplate . }}

# Include (use named template)
{{ include "my-app.fullname" . }}

Values Per Environment

values-production.yaml

# values-production.yaml
replicaCount: 3

image:
  tag: "v1.2.3"

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

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20

ingress:
  enabled: true
  hosts:
    - host: app.company.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: app-tls
      hosts:
        - app.company.com
# Install with environment-specific values
helm install my-app ./my-app \
  -f values.yaml \
  -f values-production.yaml \
  -n production

Chart Dependencies

Declaring Dependencies

# Chart.yaml
dependencies:
  - name: postgresql
    version: "12.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled
  
  - name: redis
    version: "17.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled
# Download dependencies
helm dependency update

# Dependencies stored in charts/
my-app/
└── charts/
    ├── postgresql-12.1.5.tgz
    └── redis-17.0.1.tgz

Configuring Sub-charts

# values.yaml
postgresql:
  enabled: true
  auth:
    postgresPassword: "secretpassword"
    database: "myapp"
  primary:
    persistence:
      size: 10Gi

redis:
  enabled: true
  architecture: standalone
  auth:
    enabled: true
    password: "redispassword"

Testing Charts

Lint

# Validate chart syntax
helm lint ./my-app

# Lint with specific values
helm lint ./my-app -f values-production.yaml

Template Rendering

# Render templates locally (don't install)
helm template my-release ./my-app

# Render with values
helm template my-release ./my-app -f values-production.yaml

# Render specific template
helm template my-release ./my-app -s templates/deployment.yaml

Dry Run

# Simulate install
helm install my-release ./my-app --dry-run --debug

Chart Testing

# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "my-app.fullname" . }}-test-connection"
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": test
spec:
  containers:
    - name: wget
      image: busybox
      command: ['wget']
      args: ['{{ include "my-app.fullname" . }}:{{ .Values.service.port }}']
  restartPolicy: Never
# Run tests
helm test my-release

Helm Hooks

Execute actions at specific points in the release lifecycle:

# templates/pre-install-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: "{{ include "my-app.fullname" . }}-db-migrate"
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-5"
    "helm.sh/hook-delete-policy": hook-succeeded
spec:
  template:
    spec:
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["./migrate.sh"]
      restartPolicy: Never
  backoffLimit: 1

Hook types:

  • pre-install, post-install
  • pre-upgrade, post-upgrade
  • pre-rollback, post-rollback
  • pre-delete, post-delete

Packaging and Publishing

Package Chart

# Create .tgz package
helm package ./my-app

# Package with specific version
helm package ./my-app --version 1.2.3

# Output: my-app-1.2.3.tgz

Host Repository

# Generate index.yaml for repository
helm repo index . --url https://charts.company.com

# Directory structure
charts/
├── index.yaml
├── my-app-1.0.0.tgz
├── my-app-1.1.0.tgz
└── my-app-1.2.0.tgz

GitHub Pages Repository

# .github/workflows/release.yml
name: Release Charts

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Configure Git
        run: |
          git config user.name "$GITHUB_ACTOR"
          git config user.email "[email protected]"

      - name: Install Helm
        uses: azure/setup-helm@v3

      - name: Run chart-releaser
        uses: helm/[email protected]
        env:
          CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

When NOT to Use Helm

  • Simple, single-file manifests: kubectl apply is enough
  • Kustomize does the job: overlays might be simpler for your use case
  • GitOps with ArgoCD: ArgoCD can handle plain YAML or Kustomize
  • Very dynamic config: Sometimes a script generating YAML is clearer

Key Takeaways

  1. Start with existing charts — don’t reinvent the wheel for databases, monitoring, etc.
  2. Use values files per environment — don’t hardcode anything
  3. Template everything configurable — image tags, replicas, resources
  4. Use _helpers.tpl for reusable template functions
  5. Always lint and template before install — catch errors early
  6. Version your charts — Chart.yaml version for chart changes, appVersion for app
  7. Use hooks for migrations and setup tasks

Helm isn’t just about templating—it’s about treating your Kubernetes manifests as proper software packages with versioning, dependencies, and distribution.