HELM
COMPLETE TUTORIAL
DevOps Architect Level
14 Sections | Real-World Examples | Interview Ready
From Installation to Production Architecture
Section Topic
1 Introduction to Helm — What, Why, and Comparison
2 Helm Basics — Installation and CLI Commands
3 Chart Structure — Every File Explained in Detail
4 Creating Your First Helm Chart — Step by Step
5 Templates & Values — The Core Engine
6 Managing Releases — Install, Upgrade, Rollback
7 Dependency Management — Subcharts and Best Practices
8 Helm with Kubernetes — ConfigMaps, Secrets, Environments
9 Helm Best Practices — Production-Grade Design
10 Helm + CI/CD — Pipelines and GitOps
11 Debugging & Troubleshooting — Real Problems Solved
12 Advanced Concepts — Hooks, Plugins, Secrets, Security
13 Real-World Architecture — Multi-Service E-Commerce Platform
14 Hands-On Exercises — Practice Scenarios
1 Introduction to Helm
What is Helm?
Helm is a package manager for Kubernetes. Just like apt installs software on Ubuntu, npm installs JavaScript
packages, and pip installs Python libraries — Helm installs applications on Kubernetes.
It takes your application — which might need 10 different Kubernetes resources — and lets you install, upgrade,
and remove everything as one single unit.
# One command installs EVERYTHING your app needs
helm install my-app ./my-chart
# One command upgrades it
helm upgrade my-app ./my-chart --set [Link]=v2.0
# One command rolls it back
helm rollback my-app
# One command removes everything
helm uninstall my-app
Why Helm is Needed — Real Problems It Solves
Problem 1 — Too Many YAML Files
Deploying even a simple app to Kubernetes requires managing multiple files. Without Helm, you apply them one
by one:
# Without Helm — you manage all these files manually
kubectl apply -f [Link]
kubectl apply -f [Link]
kubectl apply -f [Link]
kubectl apply -f [Link]
kubectl apply -f [Link]
kubectl apply -f [Link]
kubectl apply -f [Link]
kubectl apply -f [Link]
# With Helm — one command does it all
helm install my-app ./my-chart
Problem 2 — Copy-Pasting YAML for Each Environment
Without Helm, you keep separate copies of YAML for dev, staging, and production. 3 environments × 8 files = 24
YAML files to maintain. Change image tag? Edit every file.
# WITHOUT Helm — copy-paste nightmare
dev/[Link] replicas: 1, image: app:dev
staging/[Link] replicas: 2, image: app:staging
production/[Link] replicas: 5, image: app:v1.2.0
# WITH Helm — one chart, one values file per environment
my-chart/ one chart for all environments
[Link] dev overrides
[Link] staging overrides
[Link] production overrides
# Update prod image tag:
helm upgrade my-app ./my-chart -f [Link] --set [Link]=v1.3.0
Problem 3 — No Rollback Mechanism
With plain kubectl, if a deployment breaks, going back means manually finding and re-applying old YAML. With
Helm, rollback is one command:
# Something broke after upgrade
helm rollback my-app
# Instantly restores previous working version
# Go back to a specific version
helm rollback my-app 2
Problem 4 — No Release Tracking
With plain kubectl you have no visibility into what version is deployed, when it was changed, or what changed
between deployments. Helm gives you a full audit trail:
helm history my-app
REVISION STATUS CHART DESCRIPTION
1 superseded my-app-1.0.0 Install complete
2 superseded my-app-1.0.0 Upgrade: image v1.1
3 deployed my-app-1.0.0 Upgrade: image v1.2
How Helm Compares to Plain Kubernetes YAML
Feature Plain YAML vs Helm
Install app kubectl apply -f (many files) vs helm install (one command)
Update app Edit file + re-apply vs helm upgrade
Rollback Manual, find old files vs helm rollback
Environment config Duplicate YAML files vs values override files
Track deployments No visibility vs helm history
Package and share Zip files manually vs helm chart package
Dependency management Manual vs [Link] dependencies
Template logic None (static YAML) vs full Go templating
When to Use Each
Plain YAML is fine for: learning Kubernetes, single one-off resources, very simple personal projects.
Helm is needed for: real applications, multiple environments, teams, CI/CD pipelines, anything in
production.
2 Helm Basics
Installation — Step by Step
On Linux (Most Common for DevOps)
# Method 1 — Official script (quickest)
curl [Link] | bash
# Method 2 — Manual install (more control)
# Step 1: download the binary
wget [Link]
# Step 2: extract it
tar -zxvf [Link]
# Step 3: move to system path
mv linux-amd64/helm /usr/local/bin/helm
# Step 4: verify installation
helm version
# [Link]{Version:"v3.13.0", ...}
On Mac and Windows
# Mac
brew install helm
# Windows
choco install kubernetes-helm
Helm CLI Commands — Complete Reference
Group 1 — Release Management (Most Used Daily)
# Install a chart as a new release
helm install <release-name> <chart-path>
helm install my-app ./my-chart
helm install my-app ./my-chart -n production # specific namespace
helm install my-app ./my-chart -f [Link] # custom values file
helm install my-app ./my-chart --set [Link]=v2.0 # override single value
# Upgrade an existing release
helm upgrade my-app ./my-chart
helm upgrade my-app ./my-chart -f [Link]
# Install OR upgrade — most used command in CI/CD (idempotent)
helm upgrade --install my-app ./my-chart -f [Link]
# Rollback
helm rollback my-app # one revision back
helm rollback my-app 2 # to specific revision number
# Uninstall — removes ALL resources created by this release
helm uninstall my-app
helm uninstall my-app -n production
Group 2 — Inspection Commands
# List all releases
helm list
helm list -n production
helm list --all-namespaces
# Release details
helm status my-app
helm history my-app
# See what values are currently applied
helm get values my-app
helm get values my-app --all # including all defaults
# See all kubernetes manifests of a live release
helm get manifest my-app
# Dry run — see what WOULD be deployed without applying
helm install my-app ./my-chart --dry-run
helm install my-app ./my-chart --dry-run --debug # with extra detail
Group 3 — Chart Development Commands
# Create a new chart (generates full folder structure)
helm create my-chart
# Check chart for syntax and best-practice errors
helm lint ./my-chart
helm lint ./my-chart -f [Link]
# Render templates locally — see exact YAML without installing
helm template my-app ./my-chart
helm template my-app ./my-chart -f [Link]
# Package chart into .tgz for sharing
helm package ./my-chart
# Download chart dependencies
helm dependency update ./my-chart
Helm Repository — Add, Update, Search
A Helm repository is where charts are stored and shared — like npm registry but for Kubernetes applications.
Adding and Managing Repositories
# Add popular repositories
helm repo add bitnami [Link]
helm repo add ingress-nginx [Link]
helm repo add jetstack [Link]
helm repo add argo [Link]
# List added repos
helm repo list
# Update repo index (like apt-get update — do this before searching)
helm repo update
# Remove a repo
helm repo remove bitnami
Searching and Installing from Repos
# Search for a chart
helm search repo postgresql
helm search repo bitnami/postgresql
# See all available versions
helm search repo bitnami/postgresql --versions
# Read chart documentation before installing
helm show chart bitnami/postgresql
helm show values bitnami/postgresql # see all configurable values
helm show readme bitnami/postgresql
# Install directly from repo
helm install my-postgres bitnami/postgresql
helm install my-postgres bitnami/postgresql --version 12.1.0
3 Helm Chart Structure — Every File Explained
The Full Folder Structure
When you run 'helm create my-app', Helm generates this complete structure. Every file has a specific purpose.
my-app/
│
├── [Link] Chart metadata (name, version, dependencies)
├── [Link] Default configuration values
├── .helmignore Files to ignore when packaging
│
├── charts/ Downloaded dependency charts go here
│
└── templates/ Kubernetes manifest templates
├── [Link] Deployment template
├── [Link] Service template
├── [Link] Ingress template
├── [Link] HorizontalPodAutoscaler template
├── [Link] ServiceAccount template
├── [Link] ConfigMap (you add this)
├── _helpers.tpl Reusable template snippets (NOT rendered)
└── [Link] Message printed after helm install
[Link] — The Identity Card
This file describes your chart: who made it, what version, what it does, and what it depends on.
# [Link] — complete example with all fields explained
apiVersion: v2 # Always v2 for Helm 3. Do not change.
name: my-app # Chart name. Used in resource names.
description: A Helm chart for my web application
type: application # 'application' = installable
# 'library' = only provides templates for others
version: 1.3.0 # CHART version. Bump when chart structure changes.
# Follows semver: [Link]
appVersion: "2.5.1" # APPLICATION version. What your app this deploys.
# Informational only — does not affect Kubernetes.
keywords: # Helps with helm search
- web
- api
maintainers:
- name: Ali Hassan
email: ali@[Link]
dependencies: # Charts this chart depends on
- name: postgresql
version: "12.1.0" # Always use quotes around version
repository: [Link]
condition: [Link] # Only install if value is true
- name: redis
version: "17.3.0"
repository: [Link]
condition: [Link]
Key Rule — Two Separate Versions
Chart version (version field): bump when you change templates, add new values, fix chart bugs.
App version (appVersion field): update when your application releases a new version.
They are completely independent. Chart v3.0.0 can deploy app v1.0.0.
[Link] — The Control Panel
The most important file for daily usage. Every configurable aspect of your app lives here. Think of it as the control
panel — turn features on/off, set resource limits, configure environments.
# [Link] — complete production-grade example
replicaCount: 2
image:
repository: mycompany/my-app
tag: "1.0.0"
pullPolicy: IfNotPresent # Always / IfNotPresent / Never
service:
type: ClusterIP # ClusterIP / NodePort / LoadBalancer
port: 80 # port the service exposes
targetPort: 8080 # port your container listens on
ingress:
enabled: false # off by default, enable per environment
className: nginx
host: [Link]
tls:
enabled: false
secretName: myapp-tls
resources:
requests:
cpu: 250m # guaranteed minimum
memory: 256Mi
limits:
cpu: 500m # hard ceiling (CPU throttles, memory OOMKills)
memory: 512Mi
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
env:
LOG_LEVEL: "info"
APP_ENV: "production"
PORT: "8080"
config:
database_host: "localhost"
cache_ttl: "300"
serviceAccount:
create: true
name: "" # auto-generated if empty
annotations: {} # add AWS IRSA annotation here
podAnnotations: {}
# [Link]/scrape: "true"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
postgresql:
enabled: false # enable when needed per environment
auth:
username: myapp
password: "" # NEVER hardcode — pass via CI/CD
database: myappdb
Golden Rule for [Link]
If something might need to change between environments, it goes in [Link].
Never hardcode environment-specific values directly in templates.
Assume someone will need to override every value you write.
templates/ — Where Kubernetes Manifests Live
Every .yaml file in templates/ is a Kubernetes manifest with Go template syntax added. Helm reads these,
substitutes the values, and produces plain Kubernetes YAML.
[Link]
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "[Link]" . }}
labels:
{{- include "[Link]" . | nindent 4 }}
spec:
{{- if not .[Link] }}
replicas: {{ .[Link] }}
{{- end }}
selector:
matchLabels:
{{- include "[Link]" . | nindent 6 }}
template:
metadata:
labels:
{{- include "[Link]" . | nindent 8 }}
{{- with .[Link] }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "[Link]" . }}
containers:
- name: {{ .[Link] }}
image: "{{ .[Link] }}:{{ .[Link] }}"
imagePullPolicy: {{ .[Link] }}
ports:
- containerPort: {{ .[Link] }}
resources:
{{- toYaml .[Link] | nindent 12 }}
env:
{{- range $key, $value := .[Link] }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- with .[Link] }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
[Link]
apiVersion: v1
kind: Service
metadata:
name: {{ include "[Link]" . }}
labels:
{{- include "[Link]" . | nindent 4 }}
spec:
type: {{ .[Link] }}
ports:
- port: {{ .[Link] }}
targetPort: {{ .[Link] }}
protocol: TCP
name: http
selector:
{{- include "[Link]" . | nindent 4 }}
[Link] — with Conditional Rendering
Notice the entire Ingress resource is wrapped in an if block. If [Link] is false, Helm produces zero output
for this file. The resource simply does not exist.
{{- if .[Link] -}}
apiVersion: [Link]/v1
kind: Ingress
metadata:
name: {{ include "[Link]" . }}
labels:
{{- include "[Link]" . | nindent 4 }}
spec:
{{- if .[Link] }}
ingressClassName: {{ .[Link] }}
{{- end }}
{{- if .[Link] }}
tls:
- hosts:
- {{ .[Link] }}
secretName: {{ .[Link] }}
{{- end }}
rules:
- host: {{ .[Link] }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "[Link]" . }}
port:
number: {{ .[Link] }}
{{- end }}
[Link] — Horizontal Pod Autoscaler
{{- if .[Link] }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "[Link]" . }}
labels:
{{- include "[Link]" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "[Link]" . }}
minReplicas: {{ .[Link] }}
maxReplicas: {{ .[Link] }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .[Link] }}
{{- end }}
_helpers.tpl — Reusable Template Functions
The underscore prefix is special — it tells Helm: do not render this file as a Kubernetes manifest. It only defines
named templates (like utility functions) that other template files can call.
{{/*
Full name: combines release name + chart name
e.g. release 'payments' + chart 'my-app' = 'payments-my-app'
*/}}
{{- define "[Link]" -}}
{{- if .[Link] }}
{{- .[Link] | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .[Link] .[Link] }}
{{- printf "%s-%s" .[Link] $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{/*
Common labels — applied to EVERY resource for consistency
*/}}
{{- define "[Link]" -}}
[Link]/chart: {{ printf "%s-%s" .[Link] .[Link] }}
[Link]/name: {{ .[Link] }}
[Link]/instance: {{ .[Link] }}
[Link]/version: {{ .[Link] | quote }}
[Link]/managed-by: {{ .[Link] }}
{{- end }}
{{/*
Selector labels — used in matchLabels and pod templates
IMPORTANT: these cannot change after first deployment!
*/}}
{{- define "[Link]" -}}
[Link]/name: {{ .[Link] }}
[Link]/instance: {{ .[Link] }}
{{- end }}
{{/*
Service account name logic
*/}}
{{- define "[Link]" -}}
{{- if .[Link] }}
{{- default (include "[Link]" .) .[Link] }}
{{- else }}
{{- default "default" .[Link] }}
{{- end }}
{{- end }}
How to Call a Helper in Another Template
# In [Link] or any other template file:
name: {{ include "[Link]" . }}
# ^ ^ ^
# include template name context (dot = pass everything)
labels:
{{- include "[Link]" . | nindent 4 }}
# dash removes whitespace before, nindent 4 = newline + 4 spaces indent
[Link] — Post-Install Message
This file is rendered as a template and printed to the terminal after every helm install or helm upgrade. Use it to
tell users how to access the application.
# templates/[Link]
Thank you for installing {{ .[Link] }} v{{ .[Link] }}!
Release: {{ .[Link] }}
Namespace: {{ .[Link] }}
{{- if .[Link] }}
Access your app at: [Link] .[Link] }}
{{- else }}
To access locally:
kubectl port-forward svc/{{ include "[Link]" . }} 8080:
{{ .[Link] }}
Then open: [Link]
{{- end }}
View status: helm status {{ .[Link] }}
View logs: kubectl logs -l [Link]/instance={{ .[Link] }} -f
4 Creating Your First Helm Chart
Let's build a complete working Helm chart from scratch, step by step. We'll deploy a simple nginx-based web
application.
Step 1 — Generate the Chart Scaffold
helm create webapp
# This creates the full folder structure:
webapp/
├── [Link]
├── [Link]
├── charts/
└── templates/
├── [Link]
├── [Link]
├── [Link]
├── [Link]
├── [Link]
├── _helpers.tpl
└── [Link]
Step 2 — Update [Link]
# webapp/[Link]
apiVersion: v2
name: webapp
description: A simple web application chart
type: application
version: 1.0.0
appVersion: "1.0.0"
Step 3 — Define [Link]
# webapp/[Link]
replicaCount: 1
image:
repository: nginx
tag: "1.25"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
targetPort: 80
ingress:
enabled: false
host: [Link]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 80
env:
APP_ENV: production
serviceAccount:
create: true
name: ""
annotations: {}
Step 4 — Write the Deployment Template
# webapp/templates/[Link]
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "[Link]" . }}
labels:
{{- include "[Link]" . | nindent 4 }}
spec:
{{- if not .[Link] }}
replicas: {{ .[Link] }}
{{- end }}
selector:
matchLabels:
{{- include "[Link]" . | nindent 6 }}
template:
metadata:
labels:
{{- include "[Link]" . | nindent 8 }}
spec:
serviceAccountName: {{ include "[Link]" . }}
containers:
- name: webapp
image: "{{ .[Link] }}:{{ .[Link] }}"
imagePullPolicy: {{ .[Link] }}
ports:
- containerPort: {{ .[Link] }}
resources:
{{- toYaml .[Link] | nindent 12 }}
env:
{{- range $key, $value := .[Link] }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
Step 5 — Validate the Chart
# Check for syntax errors
helm lint ./webapp
# See the rendered YAML output without installing
helm template my-webapp ./webapp
# Dry run against real cluster (doesn't install, checks API compatibility)
helm install my-webapp ./webapp --dry-run --debug
Step 6 — Install, Test, and Upgrade
# Install
helm install my-webapp ./webapp
# Verify everything is running
helm list
kubectl get pods
kubectl get svc
# Access the app
kubectl port-forward svc/my-webapp 8080:80
# Open [Link]
# Upgrade — change replica count
helm upgrade my-webapp ./webapp --set replicaCount=3
# Upgrade — change image tag
helm upgrade my-webapp ./webapp --set [Link]=1.26
# Check history
helm history my-webapp
# Rollback
helm rollback my-webapp
# Clean up
helm uninstall my-webapp
5 Templates & Values — The Core Engine
Go Templating — Simple Explanation
Helm uses Go's template engine. The syntax looks intimidating at first but follows simple rules. The fundamental
concept: anything inside double braces {{ }} is processed by Helm. Everything outside is plain YAML text.
# Plain YAML — Helm passes this through unchanged
kind: Deployment
# Inside double braces — Helm processes this
name: {{ .[Link] }}
# ^
# dot = current context (contains everything)
The Dot — The Most Important Concept
The dot represents the current context — the object you are working with. At the top level it contains everything
Helm knows about your release.
# .Values — everything defined in [Link]
{{ .[Link] }}
{{ .[Link] }}
{{ .[Link] }}
# .Release — information about this release
{{ .[Link] }} # the name given at helm install
{{ .[Link] }} # kubernetes namespace
{{ .[Link] }} # true on first install
{{ .[Link] }} # true on upgrade
{{ .[Link] }} # revision number (increments each change)
# .Chart — information from [Link]
{{ .[Link] }}
{{ .[Link] }}
{{ .[Link] }}
Values Precedence — What Wins
When you pass values from multiple sources, Helm merges them in order. The last source wins.
Priority Source
1 (highest) --set flag on command line
2 --set-string flag
3 --set-file flag
4 -f custom values file
5 (lowest) [Link] defaults in the chart
# Example: all three sources combined
helm install my-app ./my-chart \
-f [Link] \ # overrides chart defaults
--set [Link]=v2.0 # overrides [Link]
# If [Link] has: [Link]: v1.0
# And --set has: [Link]: v2.0
# Result: [Link] = v2.0 (--set wins)
Conditional Logic — if, else, else if
Basic if
# Only render this block if ingress is enabled
{{- if .[Link] }}
apiVersion: [Link]/v1
kind: Ingress
...
{{- end }}
if with else
spec:
{{- if eq .[Link] "NodePort" }}
ports:
- port: {{ .[Link] }}
nodePort: {{ .[Link] }}
{{- else }}
ports:
- port: {{ .[Link] }}
{{- end }}
if, else if, else
{{- if eq .[Link] "production" }}
replicas: 5
{{- else if eq .[Link] "staging" }}
replicas: 2
{{- else }}
replicas: 1
{{- end }}
Comparison Operators
Operator Meaning Example
eq equal {{ if eq .[Link] "prod" }}
ne not equal {{ if ne .[Link] "dev" }}
lt less than {{ if lt .[Link] 3 }}
gt greater than {{ if gt .[Link] 1 }}
le less or equal {{ if le .[Link] 5 }}
ge greater or equal {{ if ge .[Link] 2 }}
and both true {{ if and .Values.a .Values.b }}
or either true {{ if or .Values.a .Values.b }}
not negate {{ if not .[Link] }}
with — Checking if Value Exists
# with checks if value exists AND changes context (dot) to that value
{{- with .[Link] }}
annotations:
{{- toYaml . | nindent 4 }}
# dot now refers to .[Link]
{{- end }}
# Same as:
{{- if .[Link] }}
annotations:
{{- toYaml .[Link] | nindent 4 }}
{{- end }}
Loops — range
Loop Over a Map (Key-Value Pairs)
# [Link]
env:
APP_ENV: production
LOG_LEVEL: info
PORT: "8080"
# template
env:
{{- range $key, $value := .[Link] }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
# Output:
env:
- name: APP_ENV
value: "production"
- name: LOG_LEVEL
value: "info"
- name: PORT
value: "8080"
Loop Over a List
# [Link]
tolerations:
- key: node-role
operator: Equal
value: worker
effect: NoSchedule
- key: dedicated
operator: Equal
value: gpu
effect: NoSchedule
# template
tolerations:
{{- range .[Link] }}
- key: {{ .key }}
operator: {{ .operator }}
value: {{ .value }}
effect: {{ .effect }}
{{- end }}
Pipes and Functions — Essential Reference
Function / Pipe What It Does — with Example
quote Wraps in quotes. PORT: {{ .[Link] | quote }} → PORT: "8080"
default Fallback if empty. {{ .[Link] | default "my-app" }}
upper / lower Change case. {{ .[Link] | upper }} → PRODUCTION
trunc 63 Truncate to 63 chars (K8s name limit). {{ .[Link] | trunc 63 }}
printf Format string. {{ printf "%s-%s" .[Link] .[Link] }}
nindent N Newline + N spaces indent. Used for nested YAML blocks.
toYaml Convert a values block to YAML. resources: {{-
toYaml .[Link] | nindent 12 }}
b64enc Base64 encode (required for secrets). {{ .[Link] | b64enc }}
required Fail with message if value missing. {{ required "tag
required" .[Link] }}
contains Check if string contains substring. {{ if contains "prod" .[Link] }}
replace Replace in string. {{ .[Link] | replace "+" "_" }}
The Whitespace Problem — Dashes Explained
This is where beginners get confused. YAML is whitespace-sensitive. Wrong indentation breaks everything.
Helm's dash syntax controls whitespace around template tags.
# WITHOUT leading dash — keeps newline before the tag
metadata:
labels:
{{ include "[Link]" . | nindent 4 }}
# Result: blank line between 'labels:' and the actual labels = INVALID YAML
# WITH leading dash — removes whitespace before the tag
metadata:
labels:
{{- include "[Link]" . | nindent 4 }}
# Result: no blank line = VALID YAML
# Rule of thumb:
# {{- removes whitespace/newline BEFORE the tag
# -}} removes whitespace/newline AFTER the tag
# {{- most commonly used — put it on almost every template tag
6 Managing Releases
Install, Upgrade, Rollback, Uninstall — Full Lifecycle
Install
# Basic install
helm install my-app ./my-chart
# With namespace (creates namespace if it doesn't exist)
helm install my-app ./my-chart --namespace production --create-namespace
# With values file
helm install my-app ./my-chart -f [Link]
# With wait — Helm waits until pods are Running + Ready
helm install my-app ./my-chart --wait --timeout 5m
# Atomic — auto-rollback if install fails (recommended for prod)
helm install my-app ./my-chart --atomic --timeout 5m
Upgrade
# Basic upgrade
helm upgrade my-app ./my-chart
# With new values
helm upgrade my-app ./my-chart -f [Link] --set [Link]=v2.0
# MOST USED IN CI/CD — installs if not exists, upgrades if exists
helm upgrade --install my-app ./my-chart -f [Link] --atomic --timeout
5m
# Keep existing values (don't reset to chart defaults)
helm upgrade my-app ./my-chart --reuse-values
# Force recreate pods even if no config change detected
helm upgrade my-app ./my-chart --force
# Limit stored history to avoid etcd memory bloat
helm upgrade my-app ./my-chart --history-max 10
Rollback
# See history first — always do this before rollback
helm history my-app
# REVISION STATUS CHART DESCRIPTION
# 1 superseded my-app-1.0.0 Install complete
# 2 superseded my-app-1.0.0 Upgrade complete
# 3 deployed my-app-1.0.0 Upgrade: image v3.0 (this broke something)
# Rollback to previous revision
helm rollback my-app
# Rollback to specific revision
helm rollback my-app 1
# Wait for pods to be ready after rollback
helm rollback my-app --wait
# NOTE: Rollback creates a NEW revision — it doesn't delete the failed one
# After rollback to revision 1, history shows revision 4 = rollback
Uninstall
# Remove ALL resources created by this release
helm uninstall my-app
helm uninstall my-app -n production
# Keep history after uninstall (for audit compliance)
helm uninstall my-app --keep-history
Versioning Concepts
Feature Explanation — Chart Version vs App Version
Chart version (version in Tracks changes to the chart itself. Bump when you add templates,
[Link]) change values, fix bugs.
App version (appVersion in Tracks which version of your application is deployed. Usually matches
[Link]) Docker image tag.
Release revision Increments every time you install or upgrade. Used for rollback. Stored
in K8s secrets.
Real-World Deployment Workflow
This is how a production team actually works with Helm day to day:
# Step 1: Developer makes code change, builds and pushes new Docker image
docker build -t mycompany/my-app:v2.3.1 .
docker push mycompany/my-app:v2.3.1
# Step 2: CI/CD pipeline starts
# Step 3: Lint the chart
helm lint ./my-chart -f [Link]
# Step 4: See WHAT WOULD CHANGE before applying (like terraform plan)
helm diff upgrade my-app ./my-chart -f [Link] --set [Link]=v2.3.1
# Step 5: Deploy to staging first
helm upgrade --install my-app ./my-chart \
-f [Link] --set [Link]=v2.3.1 \
--atomic --timeout 5m -n staging
# Step 6: Run smoke tests against staging
curl -f [Link]
# Step 7: Deploy to production with manual approval gate
helm upgrade --install my-app ./my-chart \
-f [Link] --set [Link]=v2.3.1 \
--atomic --timeout 10m --history-max 10 -n production
# Step 8: Something went wrong — rollback immediately
helm rollback my-app -n production
# Step 9: Verify rollback worked
helm status my-app -n production
kubectl get pods -n production
7 Dependency Management
What Are Dependencies?
Your application needs a database. Instead of managing it as a completely separate Helm release, declare it as a
dependency in your chart. When you install your chart, the database installs automatically alongside it.
Declaring Dependencies in [Link]
# [Link]
dependencies:
- name: postgresql # chart name in the repository
version: "12.1.0" # always pin exact version — never use *
repository: [Link]
condition: [Link] # only install when this value = true
- name: redis
version: "17.3.0"
repository: [Link]
condition: [Link]
- name: rabbitmq
version: "11.0.0"
repository: [Link]
condition: [Link]
How to Use Dependencies — Step by Step
# Step 1: Add the repositories your dependencies need
helm repo add bitnami [Link]
helm repo update
# Step 2: Download dependencies into charts/ folder
helm dependency update ./my-chart
# After this, your chart folder looks like:
# my-chart/
# ├── [Link] ← exact versions locked (commit this to git!)
# └── charts/
# ├── [Link]
# └── [Link]
# Step 3: Install — dependencies install automatically
helm install my-app ./my-chart
Configuring Dependencies via [Link]
The dependency chart's configuration goes under a key matching the dependency name. It uses the dependency
chart's own values schema.
# [Link]
# Your app values
replicaCount: 2
# PostgreSQL dependency configuration
# (these are bitnami/postgresql chart's own values)
postgresql:
enabled: true
auth:
username: myapp
password: "" # never hardcode — pass via CI/CD
database: myappdb
primary:
persistence:
enabled: true
size: 20Gi
resources:
requests:
cpu: 500m
memory: 512Mi
# Redis dependency configuration
redis:
enabled: true
auth:
enabled: false
master:
persistence:
enabled: false # no persistence needed for cache
replica:
replicaCount: 0 # no replicas in dev to save resources
Connecting Your App to the Dependency
The dependency creates a Kubernetes Service inside the cluster. The service name follows a predictable pattern
you can use in your app config.
# Service name pattern: <release-name>-<dependency-name>
# If release = 'my-app' and dependency = 'postgresql'
# Service name = 'my-app-postgresql'
# Reference it in [Link]
env:
DATABASE_HOST: "my-app-postgresql" # hardcoded — fragile
# Better — use template to build it dynamically
# In _helpers.tpl:
{{- define "[Link]" -}}
{{- printf "%s-postgresql" .[Link] }}
{{- end }}
# In [Link]:
- name: DATABASE_HOST
value: {{ include "[Link]" . }}
[Link] — Pin Your Dependency Versions
After running 'helm dependency update', Helm creates a [Link] file. This locks exact dependency versions so
every team member and CI/CD pipeline gets identical results. Always commit this file to git.
# [Link] — auto-generated, never edit manually, always commit to git
dependencies:
- name: postgresql
repository: [Link]
version: 12.1.0
- name: redis
repository: [Link]
version: 17.3.0
digest: sha256:abc123def456...
generated: "2024-01-15T10:30:00Z"
# If [Link] exists, use 'helm dependency build' to restore without re-resolving
helm dependency build ./my-chart # fast — uses locked versions
helm dependency update ./my-chart # slow — resolves and updates versions
Local Chart Dependencies
# [Link] — referencing a local chart
dependencies:
- name: common-lib
version: "1.0.0"
repository: "[Link] # relative path
- name: shared-config
version: "2.0.0"
repository: "[Link] # private registry
Best Practices for Dependencies
Always pin exact versions (12.1.0 not ^12.0.0) — prevents unexpected upgrades.
Use condition flags ([Link]) — lets you turn off dependencies per environment.
Commit [Link] to git — ensures reproducible deployments.
Run 'helm dependency update' in CI when [Link] changes.
Read the dependency chart's values with 'helm show values bitnami/postgresql' before configuring.
8 Helm with Kubernetes — Practical Usage
ConfigMaps — Two Patterns
Pattern 1 — Values-Driven ConfigMap
# [Link]
appConfig:
database_pool_size: "10"
cache_ttl: "300"
feature_new_ui: "false"
log_format: "json"
# templates/[Link]
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "[Link]" . }}-config
labels:
{{- include "[Link]" . | nindent 4 }}
data:
{{- range $key, $value := .[Link] }}
{{ $key }}: {{ $value | quote }}
{{- end }}
Pattern 2 — File-Based ConfigMap (for Config Files)
# templates/[Link]
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "[Link]" . }}-config
data:
[Link]: |
server {
listen {{ .[Link] }};
server_name {{ .[Link] }};
location /health {
return 200 'healthy';
}
}
[Link]: |
[Link]={{ .[Link].database_pool_size }}
[Link]={{ .[Link].cache_ttl }}
Secrets in Helm — The Right Approach
# templates/[Link]
apiVersion: v1
kind: Secret
metadata:
name: {{ include "[Link]" . }}-secret
labels:
{{- include "[Link]" . | nindent 4 }}
type: Opaque
data:
# b64enc encodes to base64 — required by Kubernetes Secrets
database-url: {{ .[Link] | b64enc | quote }}
api-key: {{ .[Link] | b64enc | quote }}
# [Link] — placeholders ONLY, real values passed at deploy time
secrets:
databaseUrl: "" # pass via: --set [Link]=$DB_URL
apiKey: "" # pass via: --set [Link]=$API_KEY
# Deploy with secrets from CI/CD environment variables
helm upgrade --install my-app ./my-chart \
-f [Link] \
--set [Link]="$DATABASE_URL" \
--set [Link]="$API_KEY"
Mounting ConfigMap and Secret in Deployment
# templates/[Link] — container spec
containers:
- name: {{ .[Link] }}
image: "{{ .[Link] }}:{{ .[Link] }}"
# Load all ConfigMap keys as environment variables
envFrom:
- configMapRef:
name: {{ include "[Link]" . }}-config
# Load specific Secret keys as individual env vars
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "[Link]" . }}-secret
key: database-url
- name: API_KEY
valueFrom:
secretKeyRef:
name: {{ include "[Link]" . }}-secret
key: api-key
# Mount ConfigMap as files in a directory
volumeMounts:
- name: app-config
mountPath: /etc/app/config
readOnly: true
volumes:
- name: app-config
configMap:
name: {{ include "[Link]" . }}-config
Environment-Specific Configuration — The Full Pattern
This is the most important real-world pattern. One chart, one values file per environment, zero duplication.
File Structure
my-chart/
├── [Link]
├── [Link] ← shared defaults for ALL environments
├── [Link] ← only what's different in dev
├── [Link] ← only what's different in staging
└── [Link] ← only what's different in production
[Link] — Shared Defaults
replicaCount: 1
image:
repository: mycompany/my-app
tag: "latest"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
targetPort: 8080
ingress:
enabled: false
host: ""
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
autoscaling:
enabled: false
appConfig:
log_level: "debug"
cache_ttl: "60"
postgresql:
enabled: false
[Link] — Only Overrides
# DEV — minimal resources, debug logging, local DB
replicaCount: 1
image:
tag: "dev"
appConfig:
log_level: "debug"
cache_ttl: "10"
postgresql:
enabled: true
auth:
username: devuser
password: devpassword
database: devdb
primary:
persistence:
size: 1Gi
[Link]
# STAGING — close to production but smaller scale
replicaCount: 2
image:
tag: "staging"
ingress:
enabled: true
host: [Link]
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 400m
memory: 512Mi
appConfig:
log_level: "info"
cache_ttl: "120"
postgresql:
enabled: true
auth:
username: staginguser
password: "" # passed via CI --set
database: stagingdb
[Link]
# PRODUCTION — full resources, HA, autoscaling, TLS
replicaCount: 5
image:
tag: "1.5.0" # always pinned in prod — never 'latest'
pullPolicy: Always
ingress:
enabled: true
host: [Link]
tls:
enabled: true
secretName: myapp-tls
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
autoscaling:
enabled: true
minReplicas: 5
maxReplicas: 20
targetCPUUtilizationPercentage: 70
appConfig:
log_level: "warn"
cache_ttl: "300"
postgresql:
enabled: true
auth:
username: produser
password: "" # passed via CI secret
database: proddb
primary:
persistence:
size: 100Gi
resources:
requests:
cpu: 1000m
memory: 2Gi
Deploy Commands Per Environment
# Dev
helm upgrade --install my-app ./my-chart -f [Link] -n dev --create-namespace
# Staging
helm upgrade --install my-app ./my-chart \
-f [Link] --set [Link]="$STAGING_DB_PASS" \
-n staging
# Production — with atomic and manual password
helm upgrade --install my-app ./my-chart \
-f [Link] \
--set [Link]="$RELEASE_TAG" \
--set [Link]="$PROD_DB_PASS" \
--atomic --timeout 10m --history-max 10 \
-n production
9 Helm Best Practices — Production Level
Chart Design Principles
1. Sensible Defaults That Work Out of the Box
# BAD — user must set everything or it fails
image:
repository: "" # blank — will fail immediately
tag: "" # blank — will fail immediately
# GOOD — works with defaults, can be overridden
image:
repository: mycompany/my-app
tag: "latest"
2. Required Values Should Fail with Clear Messages
# templates/[Link]
image: "{{ required "ERROR: [Link] must be set" .[Link] }}:
{{ required "ERROR: [Link] must be set" .[Link] }}"
# If user installs without setting these, they get an immediate, clear error
3. Feature Flags for Optional Components
# Every optional component should have an enabled flag
ingress:
enabled: false # off by default, enable per environment
autoscaling:
enabled: false
monitoring:
enabled: false
postgresql:
enabled: false
4. Standard Kubernetes Labels on Every Resource
# Apply these recommended labels to every resource
labels:
[Link]/name: {{ include "[Link]" . }}
[Link]/instance: {{ .[Link] }}
[Link]/version: {{ .[Link] | quote }}
[Link]/managed-by: {{ .[Link] }}
[Link]/chart: {{ printf "%s-%s" .[Link] .[Link] }}
# Why: enables 'kubectl get all -l [Link]/instance=my-app'
Library Charts — One Template for 50 Microservices
When you have many microservices, they all share the same deployment pattern. Library charts define templates
once and every service reuses them.
# common-lib/[Link]
apiVersion: v2
name: common-lib
type: library # cannot be installed directly
version: 1.0.0
# common-lib/templates/_deployment.tpl
{{- define "[Link]" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "[Link]" . }}
labels:
{{- include "[Link]" . | nindent 4 }}
spec:
replicas: {{ .[Link] | default 1 }}
selector:
matchLabels:
{{- include "[Link]" . | nindent 6 }}
template:
metadata:
labels:
{{- include "[Link]" . | nindent 8 }}
spec:
containers:
- name: {{ .[Link] }}
image: "{{ .[Link] }}:{{ .[Link] }}"
resources:
{{- toYaml .[Link] | nindent 12 }}
{{- end }}
# any-microservice/[Link]
dependencies:
- name: common-lib
version: "1.0.0"
repository: "[Link]
# any-microservice/templates/[Link]
# Just one line — the entire deployment comes from the library
{{ include "[Link]" . }}
Why Library Charts Matter at Scale
50 microservices all use the same deployment template.
When you fix a security issue in the template (e.g., add readOnlyRootFilesystem), update the library — all
50 services get the fix on next deploy.
Developers only write business logic, not boilerplate Kubernetes YAML.
Versioning Strategy
Change Type Version Bump Example
Bug fix in template Patch: 1.0.0 → 1.0.1 Fix wrong label name
New optional value added Minor: 1.0.0 → 1.1.0 Add tolerations support
Breaking change (renamed Major: 1.0.0 → 2.0.0 Renamed [Link] to
value) [Link]
Production Safety Rules
Rule Why It Matters
Always use --atomic in Auto-rollback if deployment fails. Without it, failed upgrades leave
production release in broken state.
Always set --history-max 10 Helm stores history as K8s secrets. Unlimited history bloats etcd
memory.
Never use image tag 'latest' in latest is mutable — you never know exactly what's running. Pin to exact
prod version.
Lint before every deployment Catches template errors before they hit the cluster.
Use --wait for production Helm reports success only after pods are Running and Ready.
deploys
Pass secrets via CI --set, never Secrets in files get committed to git. Environment variables are masked
in files in CI logs.
Keep values files minimal Only override what's different. Less duplication = fewer mistakes.
10 Helm + CI/CD Integration
How Helm Fits in a Pipeline
Code Push
↓
CI runs: lint, tests, security scan
↓
Build Docker image → push to registry
↓
helm lint + helm template validation
↓
helm diff (show what would change)
↓
helm upgrade --install to staging + smoke tests
↓
Manual approval gate
↓
helm upgrade --install to production
↓
Post-deploy health check
GitLab CI Pipeline — Complete Example
# .[Link]
variables:
APP_NAME: my-app
CHART_PATH: ./helm/my-app
REGISTRY: [Link]/mycompany
stages:
- test
- build
- validate
- deploy-staging
- deploy-production
# ── Test stage ───────────────────────────────────────
unit-tests:
stage: test
image: node:18
script:
- npm ci
- npm test
only:
- merge_requests
- main
# ── Build Docker image ────────────────────────────────
build-image:
stage: build
image: docker:24
services:
- docker:dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $REGISTRY/$APP_NAME:$CI_COMMIT_SHORT_SHA .
- docker push $REGISTRY/$APP_NAME:$CI_COMMIT_SHORT_SHA
only:
- main
- tags
# ── Helm validation ───────────────────────────────────
helm-lint:
stage: validate
image: alpine/helm:3.13.0
script:
- helm lint $CHART_PATH -f $CHART_PATH/[Link]
only:
- merge_requests
- main
helm-diff:
stage: validate
image: alpine/helm:3.13.0
script:
- helm plugin install [Link]
- helm diff upgrade $APP_NAME $CHART_PATH
-f $CHART_PATH/[Link]
--set [Link]=$CI_COMMIT_SHORT_SHA -n staging
only:
- main
# ── Deploy staging ────────────────────────────────────
deploy-staging:
stage: deploy-staging
image: alpine/helm:3.13.0
script:
- helm upgrade --install $APP_NAME $CHART_PATH
-f $CHART_PATH/[Link]
--set [Link]=$CI_COMMIT_SHORT_SHA
--set [Link]=$REGISTRY/$APP_NAME
--atomic --timeout 5m --history-max 10
-n staging --create-namespace
environment:
name: staging
url: [Link]
only:
- main
smoke-test:
stage: deploy-staging
image: curlimages/curl
script:
- sleep 10
- curl -f [Link]
needs: [deploy-staging]
only:
- main
# ── Deploy production ─────────────────────────────────
deploy-production:
stage: deploy-production
image: alpine/helm:3.13.0
script:
- helm upgrade --install $APP_NAME $CHART_PATH
-f $CHART_PATH/[Link]
--set [Link]=$CI_COMMIT_TAG
--set [Link]=$REGISTRY/$APP_NAME
--set [Link]=$PROD_DB_PASSWORD
--atomic --timeout 10m --history-max 10
-n production --create-namespace
environment:
name: production
url: [Link]
when: manual # human must approve
only:
- tags # only deploy tagged releases
GitOps with ArgoCD — How It Works
GitOps means Git is the single source of truth for what is deployed. ArgoCD watches Git and automatically makes
the cluster match it. You never run helm install manually in production.
Workflow:
1. Developer pushes change to [Link] in Git
2. ArgoCD detects the change (polls every 3 min or via webhook)
3. ArgoCD runs helm template to render manifests from the chart
4. ArgoCD compares rendered output to what's running in the cluster
5. ArgoCD applies the diff (equivalent to helm upgrade)
6. Cluster now matches Git exactly
If someone manually changes something in the cluster:
ArgoCD detects drift → auto-reverts to what Git says (selfHeal: true)
ArgoCD Application Using a Helm Chart
# [Link]
apiVersion: [Link]/v1alpha1
kind: Application
metadata:
name: my-app-production
namespace: argocd
spec:
project: default
source:
repoURL: [Link]
targetRevision: main # git branch or tag
path: charts/my-app # path to chart in repo
helm:
valueFiles:
- [Link]
- [Link]
parameters:
- name: [Link]
value: "1.5.0"
destination:
server: [Link]
namespace: production
syncPolicy:
automated:
prune: true # delete K8s resources removed from chart
selfHeal: true # auto-revert manual changes in cluster
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
retry:
limit: 3
backoff:
duration: 5s
factor: 2
maxDuration: 3m
GitOps Trade-offs
PRO: Full audit trail in Git. Drift detection. Easy rollback (revert commit). Self-healing clusters.
PRO: No manual kubectl in production. Consistent with infrastructure-as-code principles.
CON: ArgoCD sync can lag up to 3 minutes. Configure webhook for immediate sync on push.
CON: Secrets management is hard — never commit secrets to Git. Use External Secrets Operator or
Sealed Secrets.
CON: Bootstrap problem — who installs ArgoCD? Answer: Terraform or a manual one-time bootstrap
script.
11 Debugging & Troubleshooting
Debug Commands — Your Toolkit
Before Deploying
# Check chart for syntax and best-practice errors
helm lint ./my-chart
helm lint ./my-chart -f [Link]
# Render templates locally — see EXACT YAML that will be applied
# No cluster needed for this command
helm template my-app ./my-chart -f [Link]
# Render and validate against cluster API without applying
helm template my-app ./my-chart -f [Link] \
| kubectl apply --dry-run=client -f -
# Render with debug output (shows template processing steps)
helm install my-app ./my-chart --dry-run --debug
After Deploying
# Check release status
helm status my-app -n production
# See what values are currently live on the cluster
helm get values my-app -n production
helm get values my-app -n production --all # including defaults
# See the actual Kubernetes manifests that were applied
helm get manifest my-app -n production
# See the rendered [Link]
helm get notes my-app -n production
# Full release info (values + manifest + notes combined)
helm get all my-app -n production
# Release history
helm history my-app -n production
Common Problems and Solutions
Problem 1 — Release Already Exists
# Error:
# INSTALLATION FAILED: cannot re-use a name that is still in use
# Solution A — use upgrade --install instead (idempotent)
helm upgrade --install my-app ./my-chart
# Solution B — uninstall then reinstall
helm uninstall my-app
helm install my-app ./my-chart
Problem 2 — Resource Already Exists in Cluster
# Error:
# rendered manifests contain a resource that already exists.
# Unable to continue with install: ServiceAccount 'my-app' already exists
# Solution — tell Helm to adopt the existing resource
kubectl annotate serviceaccount my-app \
[Link]/release-name=my-app \
[Link]/release-namespace=production
kubectl label serviceaccount my-app \
[Link]/managed-by=Helm
# Now Helm owns the resource and can manage it
Problem 3 — Pods Not Starting After Deploy
# Step 1: check release status
helm status my-app
# Step 2: find the failing pod
kubectl get pods -n production -l [Link]/instance=my-app
# Step 3: describe it — look at the Events section at the bottom
kubectl describe pod <pod-name> -n production
# Step 4: check logs
kubectl logs <pod-name> -n production
kubectl logs <pod-name> -n production --previous # if pod crashed and restarted
# Common root causes:
# ImagePullBackOff → wrong image name/tag, missing registry credentials
# CrashLoopBackOff → application is crashing — check logs for stack trace
# OOMKilled → memory limit too low — increase .[Link]
# Pending → not enough node resources — check kubectl describe node
Problem 4 — Values Not Being Applied
# Check what values Helm is actually using for this release
helm get values my-app --all
# Re-render with your values file to see what would be generated
helm template my-app ./my-chart -f [Link] | grep -A 5 'image:'
# Validate your values file is proper YAML (syntax errors cause silent failures)
python3 -c "import yaml; yaml.safe_load(open('[Link]')); print('YAML OK')"
# Remember values precedence: --set overrides -f which overrides [Link]
Problem 5 — Upgrade Stuck in pending-upgrade State
# Check state
helm list -n production
# STATUS: pending-upgrade ← stuck
# Option A — force rollback
helm rollback my-app -n production
# Option B — if that fails, the release secret may be corrupted
# Helm stores state in Kubernetes secrets named: [Link].v1.<name>.v<revision>
kubectl get secret -n production | grep [Link]
# Option C — last resort: uninstall keeping history, then reinstall
helm uninstall my-app --keep-history -n production
helm install my-app ./my-chart -f [Link] -n production
Problem 6 — Template Rendering Error
# Error:
# template: my-app/templates/[Link]:18:
# executing ... at <.[Link]>: nil pointer evaluating interface {}.tag
# Step 1: look at line 34 of your [Link]
# Step 2: check that [Link] has '[Link]' defined
# Step 3: run with debug for more info
helm install my-app ./my-chart --dry-run --debug 2>&1 | head -100
# Step 4: validate rendered YAML is valid
helm template my-app ./my-chart | python3 -c "
import sys, yaml
docs = list(yaml.safe_load_all([Link]))
print(f'OK: {len(docs)} valid documents')
"
12 Advanced Concepts — Architect Level
Helm Hooks — Run Jobs at Specific Points
Hooks are Kubernetes resources (usually Jobs) that execute at specific points in the Helm release lifecycle. The
most common use case is database migrations.
Hook Annotation When It Runs
pre-install Before any chart resources are created on first install
post-install After all chart resources are created on first install
pre-upgrade Before upgrade starts — use for DB migrations
post-upgrade After upgrade completes — use for cache warming
pre-rollback Before rollback begins
post-rollback After rollback completes
pre-delete Before uninstall begins
post-delete After uninstall completes
test When you run: helm test my-app
Database Migration Hook — Real-World Example
# templates/[Link]
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "[Link]" . }}-migration
annotations:
# This annotation makes it a hook
"[Link]/hook": pre-install,pre-upgrade
# ↑ Run migrations BEFORE both install and every upgrade
"[Link]/hook-weight": "-5"
# ↑ Order among hooks. Lower number runs first. Default is 0.
"[Link]/hook-delete-policy": before-hook-creation,hook-succeeded
# before-hook-creation → delete old job before creating a new one
# hook-succeeded → delete job after it succeeds (keeps cluster clean)
# hook-failed → delete job if it fails
spec:
backoffLimit: 3 # retry up to 3 times before marking failed
template:
spec:
restartPolicy: Never
containers:
- name: migration
image: "{{ .[Link] }}:{{ .[Link] }}"
command: ["/bin/sh", "-c"]
args:
- |
echo 'Running database migrations...'
./[Link]
echo 'Migrations complete.'
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "[Link]" . }}-secret
key: database-url
Test Hook — Verify App After Install
# templates/tests/[Link]
apiVersion: v1
kind: Pod
metadata:
name: {{ include "[Link]" . }}-test
annotations:
"[Link]/hook": test
"[Link]/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
restartPolicy: Never
containers:
- name: test
image: curlimages/curl:latest
command: ["curl", "-f"]
args:
- "[Link] include "[Link]" . }}:{{ .[Link] }}/health"
# Run the test:
helm test my-app -n production
# Executes the pod and reports PASSED or FAILED
Essential Helm Plugins
# helm-diff — show what would change before applying (like terraform plan)
helm plugin install [Link]
helm diff upgrade my-app ./my-chart -f [Link]
# Shows: resources added, changed, or deleted — with exact line-by-line diffs
# ──────────────────────────────────────────────────────────────
# helm-secrets — encrypt/decrypt secret files using SOPS
helm plugin install [Link]
# Encrypt a secrets file with AWS KMS
sops -e --kms arn:aws:kms:us-east-1:123:key/abc [Link] > values-
[Link]
# Deploy with encrypted file — plugin decrypts transparently
helm secrets upgrade --install my-app ./my-chart \
-f [Link] -f [Link]
# ──────────────────────────────────────────────────────────────
# helm-unittest — unit test your chart templates
helm plugin install [Link]
# Write test file: tests/deployment_test.yaml
suite: deployment tests
tests:
- it: should set correct replica count
set:
replicaCount: 3
asserts:
- equal:
path: [Link]
value: 3
helm unittest ./my-chart
Secrets Management — Three Options
Option 1 — External Secrets Operator (Recommended)
The cleanest approach. ESO syncs secrets from AWS Secrets Manager (or Vault, GCP, Azure) into Kubernetes
Secrets automatically. No secrets in Helm values, no secrets in Git.
# templates/[Link]
apiVersion: [Link]/v1beta1
kind: ExternalSecret
metadata:
name: {{ include "[Link]" . }}-secret
spec:
refreshInterval: 1h # re-sync from AWS every hour
secretStoreRef:
name: aws-secretsmanager # which store to use
kind: ClusterSecretStore
target:
name: {{ include "[Link]" . }}-secret
creationPolicy: Owner
data:
- secretKey: database-url # key in K8s Secret
remoteRef:
key: production/my-app # key in AWS Secrets Manager
property: database_url # field within the JSON secret
- secretKey: api-key
remoteRef:
key: production/my-app
property: api_key
Option 2 — Sealed Secrets (for GitOps)
# Install controller
helm install sealed-secrets sealed-secrets/sealed-secrets -n kube-system
# Create encrypted secret (safe to commit to git — only cluster can decrypt it)
kubectl create secret generic my-secret \
--from-literal=db-password=supersecret \
--dry-run=client -o yaml | kubeseal --format yaml > [Link]
# Place [Link] in templates/ — Helm deploys it
# Controller decrypts it in the cluster
# Result: a real Kubernetes Secret that no human outside the cluster can read
Option 3 — Pass at Deploy Time from CI/CD
# Secrets live in GitLab CI/CD masked variables
# Passed only at deploy time — never in files, never in git
helm upgrade --install my-app ./my-chart \
-f [Link] \
--set [Link]="$PROD_DB_PASSWORD" \
--set [Link]="$API_KEY"
# Simple. Works everywhere. No extra tooling. Best for small teams.
Multi-Environment Strategy — Architect Pattern
# Recommended folder structure for enterprise Helm management
environments/
├── base/
│ └── my-chart/ ← the actual Helm chart
│ ├── [Link]
│ ├── [Link] ← shared defaults
│ └── templates/
│
├── dev/
│ └── [Link] ← dev overrides only
│
├── staging/
│ └── [Link] ← staging overrides only
│
└── production/
├── [Link] ← prod overrides only
└── [Link] ← encrypted secrets (SOPS)
# Deploy script — same pattern for all environments:
ENV=production
helm upgrade --install my-app ./environments/base/my-chart \
-f ./environments/base/my-chart/[Link] \
-f ./environments/${ENV}/[Link] \
--atomic -n ${ENV} --create-namespace
Security Considerations
# 1. Scan rendered templates for misconfigurations BEFORE deploying
helm template my-app ./my-chart -f [Link] | checkov -f -
helm template my-app ./my-chart -f [Link] | trivy config -
helm template my-app ./my-chart -f [Link] | kube-score score -
# 2. Use image digest instead of tag in production
# Tags are mutable — someone can overwrite them
# Digest is immutable — always the exact same image
image:
repository: myapp
digest: "sha256:abc123def456..." # not tag: '1.5.0'
# 3. Set security context on every production deployment
securityContext:
runAsNonRoot: true
runAsUser: 10001
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
# 4. Restrict who can read Helm release secrets in Kubernetes
# Helm stores release state as K8s secrets
# Use RBAC to ensure only platform team can read them in production namespace
13 Real-World Architecture — E-Commerce Platform
A complete multi-service e-commerce platform: frontend (React), API ([Link]), worker (background jobs), and
shared infrastructure (PostgreSQL + Redis). This is how a real production system is structured with Helm.
Full Folder Structure
helm/
│
├── charts/
│ │
│ ├── frontend/ Helm chart for React frontend (Nginx)
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ └── templates/
│ │ ├── _helpers.tpl
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link] Nginx config
│ │ └── [Link]
│ │
│ ├── api/ Helm chart for [Link] API
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ └── templates/
│ │ ├── _helpers.tpl
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link] PodDisruptionBudget
│ │ └── [Link] Helm hook for DB migrations
│ │
│ ├── worker/ Background job processor
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ └── templates/
│ │ ├── [Link]
│ │ └── [Link] KEDA ScaledObject based on queue depth
│ │
│ └── infrastructure/ Shared DB + Cache
│ ├── [Link] (has postgresql + redis as dependencies)
│ ├── [Link]
│ └── [Link]
│
└── argocd/
├── staging/
│ ├── [Link] ArgoCD Application
│ ├── [Link]
│ ├── [Link]
│ └── [Link]
└── production/
├── [Link]
├── [Link]
├── [Link]
└── [Link]
infrastructure/[Link]
apiVersion: v2
name: infrastructure
description: Shared PostgreSQL and Redis for the platform
type: application
version: 1.0.0
appVersion: "1.0.0"
dependencies:
- name: postgresql
version: "12.5.6"
repository: [Link]
condition: [Link]
- name: redis
version: "18.1.5"
repository: [Link]
condition: [Link]
api/[Link]
replicaCount: 5
image:
repository: [Link]/mycompany/ecommerce-api
tag: "3.5.0"
pullPolicy: Always
service:
type: ClusterIP
port: 80
targetPort: 3000
ingress:
enabled: true
className: nginx
host: [Link]
tls:
enabled: true
secretName: api-tls
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
autoscaling:
enabled: true
minReplicas: 5
maxReplicas: 30
targetCPUUtilizationPercentage: 70
pdb:
enabled: true
minAvailable: 3 # always keep at least 3 pods running during deploys
appConfig:
log_level: "warn"
cache_ttl: "300"
db_pool_size: "20"
redis_host: "infrastructure-redis-master"
db_host: "infrastructure-postgresql"
serviceAccount:
create: true
annotations:
[Link]/role-arn: arn:aws:iam::123456789:role/ecommerce-api
# IRSA — pod gets AWS IAM role without access keys
api/templates/[Link]
{{- if .[Link] }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "[Link]" . }}
labels:
{{- include "[Link]" . | nindent 4 }}
spec:
minAvailable: {{ .[Link] }}
selector:
matchLabels:
{{- include "[Link]" . | nindent 6 }}
{{- end }}
ArgoCD Application — API Production
# argocd/production/[Link]
apiVersion: [Link]/v1alpha1
kind: Application
metadata:
name: ecommerce-api-production
namespace: argocd
annotations:
[Link]/[Link]: deployments
[Link]/[Link]: alerts
spec:
project: production
source:
repoURL: [Link]
targetRevision: main
path: helm/charts/api
helm:
valueFiles:
- [Link]
- [Link]
destination:
server: [Link]
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
retry:
limit: 3
backoff:
duration: 5s
factor: 2
maxDuration: 3m
Architecture Decisions and Trade-offs
Decision Why Trade-off
Separate chart per service Each service deploys independently. More charts to maintain. Solved by:
API team doesn't wait for frontend library chart for shared templates.
team.
Separate infrastructure DB doesn't deploy on every app Two Helm releases to coordinate.
chart release. Different lifecycle, different Solved by: ArgoCD sync waves.
team owns it.
ArgoCD for GitOps Drift detection, self-healing, full audit ArgoCD itself needs managing. 3-min
trail in Git, no manual kubectl in prod. sync lag (mitigated with webhooks).
ESO for secrets No secrets in Git, no secrets in values ESO controller must be deployed and
files, automatic rotation support. configured first — bootstrap complexity.
PodDisruptionBudget on Guarantees minimum 3 pods during Slower rolling deploys — must wait for
API node drains and rolling upgrades. pods to come up before draining more.
14 Hands-On Exercises
Exercise 1 — Create a Chart From Scratch
Scenario
Create a Helm chart for a [Link] API. It should support: configurable replicas, image tag override,
environment variables, and optional ingress.
Goal: install it, upgrade it, then roll it back.
# Step 1: generate scaffold
helm create nodeapi
# Step 2: update [Link]
cat > nodeapi/[Link] << 'EOF'
replicaCount: 1
image:
repository: node
tag: "18-alpine"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
targetPort: 3000
ingress:
enabled: false
host: [Link]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
env:
NODE_ENV: production
PORT: "3000"
EOF
# Step 3: lint and render
helm lint ./nodeapi
helm template my-nodeapi ./nodeapi
# Step 4: install
helm install my-nodeapi ./nodeapi
# Step 5: verify
helm list
kubectl get pods
kubectl get svc
# Step 6: upgrade — change replicas
helm upgrade my-nodeapi ./nodeapi --set replicaCount=3
kubectl get pods # should show 3 pods
# Step 7: upgrade — change image tag
helm upgrade my-nodeapi ./nodeapi --set [Link]=20-alpine
# Step 8: view history
helm history my-nodeapi
# Step 9: rollback to first install
helm rollback my-nodeapi 1
helm history my-nodeapi # revision 4 = rollback to 1
# Step 10: clean up
helm uninstall my-nodeapi
Exercise 2 — Environment-Specific Values
Scenario
The nodeapi chart from Exercise 1 needs to run in dev and production with different configs.
Dev: 1 replica, debug logging. Production: 3 replicas, warn logging, ingress enabled.
# Create dev values file
cat > nodeapi/[Link] << 'EOF'
replicaCount: 1
image:
tag: "dev"
env:
NODE_ENV: development
PORT: "3000"
LOG_LEVEL: debug
EOF
# Create production values file
cat > nodeapi/[Link] << 'EOF'
replicaCount: 3
image:
tag: "1.2.0"
ingress:
enabled: true
host: [Link]
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
env:
NODE_ENV: production
PORT: "3000"
LOG_LEVEL: warn
EOF
# Deploy to dev namespace
helm upgrade --install my-nodeapi ./nodeapi \
-f nodeapi/[Link] -n dev --create-namespace
# Deploy to production namespace
helm upgrade --install my-nodeapi ./nodeapi \
-f nodeapi/[Link] -n production --create-namespace
# Verify both exist
helm list --all-namespaces
# Check the values for each
helm get values my-nodeapi -n dev
helm get values my-nodeapi -n production
Exercise 3 — Add PostgreSQL Dependency
Scenario
The nodeapi now needs a database. Add PostgreSQL as a dependency with an enabled flag.
When enabled=true, PostgreSQL installs automatically alongside the app.
# Step 1: add dependency to [Link]
cat >> nodeapi/[Link] << 'EOF'
dependencies:
- name: postgresql
version: "12.1.0"
repository: [Link]
condition: [Link]
EOF
# Step 2: add config to [Link]
cat >> nodeapi/[Link] << 'EOF'
postgresql:
enabled: true
auth:
username: nodeapi
password: "devpassword"
database: nodeapidb
primary:
persistence:
size: 1Gi
EOF
# Step 3: download dependency
helm repo add bitnami [Link]
helm repo update
helm dependency update ./nodeapi
# Verify charts/ folder has the downloaded chart
ls nodeapi/charts/
# Step 4: install with database
helm install my-nodeapi ./nodeapi
# Step 5: verify both app and DB are running
kubectl get pods
# my-nodeapi-xxxx 1/1 Running
# my-nodeapi-postgresql-0 1/1 Running
# Step 6: test you can disable it
helm upgrade my-nodeapi ./nodeapi --set [Link]=false
kubectl get pods # postgresql pod should be gone
Exercise 4 — Debug a Failing Deployment
Scenario
Deploy with a broken configuration and debug it using Helm and kubectl tools.
This simulates a real production incident investigation.
# Step 1: cause a failure — deploy with wrong image
helm upgrade --install my-nodeapi ./nodeapi \
--set [Link]=nonexistent/doesnotexist \
--set [Link]=fake
# Step 2: notice the deploy is unhealthy
helm list
# STATUS: deployed (Helm reports OK, but pods may be failing)
# Step 3: check pod status
kubectl get pods
# my-nodeapi-xxx 0/1 ImagePullBackOff
# Step 4: describe the pod — read Events section
kubectl describe pod <pod-name>
# Events: Failed to pull image 'nonexistent/doesnotexist:fake'
# Step 5: see what values Helm is using
helm get values my-nodeapi
# Step 6: rollback to last working version
helm rollback my-nodeapi
# Step 7: verify pods are healthy
kubectl get pods
# my-nodeapi-xxx 1/1 Running
# Step 8: view the history showing the failed revision
helm history my-nodeapi
# REVISION 1 — deployed (original)
# REVISION 2 — deployed (broken image)
# REVISION 3 — deployed (rollback to 1)
R Quick Reference Cheat Sheet
EF
Essential Commands
Command What It Does
helm create my-chart Generate new chart folder structure
helm lint ./my-chart Check chart for errors and best practices
helm template my-app ./my- Render templates locally without installing
chart
helm install my-app ./my-chart First-time install
helm upgrade --install my- Install if not exists, upgrade if exists (use in CI/CD)
app ./my-chart
helm upgrade my-app ./my-chart Upgrade with auto-rollback on failure
--atomic
helm list / helm list -A List releases in namespace / all namespaces
helm status my-app Show release status
helm history my-app Show revision history
helm get values my-app Show current applied values
helm get manifest my-app Show applied Kubernetes manifests
helm rollback my-app Rollback to previous revision
helm rollback my-app 2 Rollback to specific revision number
helm uninstall my-app Remove all resources from this release
helm dependency update ./my- Download declared dependencies
chart
helm package ./my-chart Package chart as .tgz for sharing
helm diff upgrade my-app ./my- Show what would change (requires helm-diff plugin)
chart
helm test my-app Run test hooks
Template Syntax Quick Reference
Syntax What It Does
{{ .[Link] }} Access a value from [Link]
{{ .[Link] }} The release name (given at helm install)
{{ .[Link] }} / Chart name and version from [Link]
{{ .[Link] }}
{{- if .[Link] }} ... {{- end Conditional block — only renders if value is true
}}
{{- if eq .[Link] "prod" }} ... Conditional with comparison
{{- end }}
{{- range $k, $v := .[Link] }} Loop over a map (key-value pairs)
{{- range .[Link] }} Loop over a list
{{- with .[Link] }} Scope to a block (also checks if not empty)
{{ .Values.x | quote }} Wrap value in quotes
{{ .Values.x | default "fallback" }} Use fallback if value is empty
{{- toYaml .[Link] | Convert value block to YAML with 12-space indent
nindent 12 }}
{{ include "[Link]" . }} Call a named template from _helpers.tpl
{{ required "msg" .Values.x }} Fail with message if value is not set
The Mental Model — Remember This
Chart = recipe (how to build the application)
Values = ingredients (what goes into it — different per environment)
Release = the cooked dish (what's actually running in your cluster)
Revision = version of the dish (every install/upgrade creates a new revision)
Rollback = serve the previous version of the dish
Repository = cookbook store (where charts are published and shared)
Helm is not magic. It is a templating engine + a release manager. Every helm install renders
your templates with your values and applies them to Kubernetes. That is the entire mental
model.