You can deploy to Kubernetes without Helm. You write 6 YAML files, apply them manually, and track versions in your head.
Then you add a staging environment. Then production. Then you need to change the image tag in 4 files for every release. Then someone applies the wrong file to the wrong cluster.
That's when Helm starts making sense.
Helm is the package manager for Kubernetes. It lets you templatize YAML, version your releases, and roll back when things break. Here's how to use it properly — from scratch.
What Helm Actually Does
Without Helm:
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
kubectl apply -f configmap.yaml
kubectl apply -f ingress.yaml
kubectl apply -f hpa.yaml
kubectl apply -f serviceaccount.yaml
With Helm:
helm install my-app ./my-chart --values production.yaml
Helm takes a collection of templatized YAML files (a "chart"), injects your values, and deploys them as a single versioned release.
Chart = Template + Values → Rendered YAML → Kubernetes Resources
Creating Your First Chart
helm create my-app
This generates:
my-app/
├── Chart.yaml # Chart metadata (name, version, description)
├── values.yaml # Default configuration values
├── charts/ # Dependencies (sub-charts)
├── templates/ # Kubernetes manifests (templatized)
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── serviceaccount.yaml
│ ├── hpa.yaml
│ ├── _helpers.tpl # Reusable template snippets
│ ├── NOTES.txt # Post-install message
│ └── tests/
│ └── test-connection.yaml
└── .helmignore # Files to exclude from packaging
Chart.yaml — Identity Card
apiVersion: v2
name: my-app
description: A payment processing microservice
type: application
version: 1.2.0 # Chart version (changes when chart changes)
appVersion: "3.4.1" # Application version (the Docker image tag)
maintainers:
- name: Sanjay S
email: sanjaysundarmurthy@gmail.com
Important distinction:
-
version= the chart packaging version. Bump this when templates or defaults change. -
appVersion= the actual application version being deployed. This goes in your image tag.
Template Basics
values.yaml — Configuration
replicaCount: 2
image:
repository: myregistry.azurecr.io/payment-service
tag: "3.4.1"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8080
ingress:
enabled: true
className: nginx
host: api.example.com
tls:
enabled: true
secretName: api-tls
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilization: 70
env:
DATABASE_URL: "postgresql://db:5432/payments"
LOG_LEVEL: "info"
CACHE_TTL: "300"
templates/deployment.yaml — Templatized Manifest
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:
labels:
{{- include "my-app.selectorLabels" . | nindent 8 }}
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
serviceAccountName: {{ include "my-app.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: {{ .Values.service.port }}
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: {{ .Values.service.port }}
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
env:
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
Key patterns explained:
| Syntax | What it does |
|---|---|
{{ .Values.replicaCount }} |
Injects value from values.yaml |
{{ include "my-app.fullname" . }} |
Calls a helper template |
{{- }} |
Trims whitespace (the - matters) |
nindent 4 |
Adds a newline + 4 spaces of indentation |
toYaml |
Converts a YAML block to properly formatted YAML |
range |
Iterates over a list or map |
quote |
Wraps value in quotes (important for strings) |
The Config Checksum Trick
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
This forces a pod restart whenever the ConfigMap changes. Without this, updating a ConfigMap doesn't restart pods — they keep using the old configuration.
_helpers.tpl — Reusable Snippets
{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Environment-Specific Values
Don't modify values.yaml for different environments. Create override files:
my-app/
├── values.yaml # Defaults
├── values-dev.yaml # Dev overrides
├── values-staging.yaml # Staging overrides
└── values-production.yaml # Production overrides
values-dev.yaml:
replicaCount: 1
image:
tag: "latest"
ingress:
host: api-dev.example.com
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
autoscaling:
enabled: false
env:
LOG_LEVEL: "debug"
values-production.yaml:
replicaCount: 3
image:
tag: "3.4.1"
ingress:
host: api.example.com
tls:
enabled: true
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
env:
LOG_LEVEL: "warn"
CACHE_TTL: "600"
Deploy with:
# Development
helm upgrade --install my-app ./my-app -f values-dev.yaml -n dev
# Production
helm upgrade --install my-app ./my-app -f values-production.yaml -n production
Values are merged: values-production.yaml overrides values.yaml. Any value not specified in the override file keeps its default.
Chart Dependencies
Your app needs Redis and PostgreSQL? Don't copy their YAML — depend on their charts.
Chart.yaml:
dependencies:
- name: redis
version: "18.6.1"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled
- name: postgresql
version: "13.4.3"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
# Download dependencies
helm dependency update ./my-app
# This creates:
# my-app/charts/redis-18.6.1.tgz
# my-app/charts/postgresql-13.4.3.tgz
Configure dependencies in values.yaml:
redis:
enabled: true
architecture: standalone
auth:
enabled: true
password: "redis-secret"
postgresql:
enabled: true
auth:
postgresPassword: "pg-secret"
database: "payments"
Production tip: Use condition fields so you can disable bundled dependencies in production (where you'd use managed services like Azure Cache for Redis or Azure Database for PostgreSQL instead).
Helm Hooks — Lifecycle Events
Run jobs at specific points during the release lifecycle:
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "my-app.fullname" . }}-db-migrate
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "1"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["python", "manage.py", "migrate"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "my-app.fullname" . }}-secrets
key: database-url
Common hooks:
| Hook | When it runs | Use case |
|---|---|---|
pre-install |
Before any resources are created | Database setup |
post-install |
After all resources are created | Smoke tests |
pre-upgrade |
Before upgrade begins | Database migrations |
post-upgrade |
After upgrade completes | Cache warming |
pre-delete |
Before deletion | Backup data |
Hook weights control execution order (lower runs first). Delete policies clean up hook resources automatically.
Helm Commands Reference
# Install a release
helm install my-app ./my-app -n production
# Upgrade (or install if not exists)
helm upgrade --install my-app ./my-app -f values-prod.yaml -n production
# See what would change (dry run)
helm upgrade --install my-app ./my-app --dry-run --debug
# Render templates locally (no cluster needed)
helm template my-app ./my-app -f values-prod.yaml
# List releases
helm list -n production
# Check release history
helm history my-app -n production
REVISION STATUS DESCRIPTION
1 superseded Install complete
2 superseded Upgrade complete
3 deployed Upgrade complete
# Rollback to revision 2
helm rollback my-app 2 -n production
# Uninstall
helm uninstall my-app -n production
# Show default values of a public chart
helm show values bitnami/redis
# Search for charts
helm search repo nginx
helm search hub prometheus
Testing Your Chart
Lint — Catch Errors Before Deploying
helm lint ./my-app
helm lint ./my-app -f values-production.yaml
Template — Render Without Deploying
helm template my-app ./my-app -f values-production.yaml > rendered.yaml
Review rendered.yaml to verify the output is correct before deploying.
Test — Verify After Deploying
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "my-app.fullname" . }}-test"
annotations:
"helm.sh/hook": test
spec:
restartPolicy: Never
containers:
- name: curl-test
image: curlimages/curl:8.5.0
command: ['curl']
args: ['{{ include "my-app.fullname" . }}:{{ .Values.service.port }}/health']
helm test my-app -n production
Packaging and Publishing
Package Your Chart
helm package ./my-app
# Creates: my-app-1.2.0.tgz
Host on Azure Container Registry (ACR)
# Login to ACR
helm registry login myregistry.azurecr.io \
--username $ACR_USERNAME \
--password $ACR_PASSWORD
# Push chart as OCI artifact
helm push my-app-1.2.0.tgz oci://myregistry.azurecr.io/helm
# Install from ACR
helm install my-app oci://myregistry.azurecr.io/helm/my-app --version 1.2.0
CI/CD Integration
# In your pipeline (GitHub Actions example)
- name: Helm Lint
run: helm lint ./charts/my-app -f values-production.yaml
- name: Helm Template
run: helm template my-app ./charts/my-app -f values-production.yaml
- name: Helm Package
run: helm package ./charts/my-app
- name: Helm Push
run: |
helm registry login ${{ secrets.ACR_LOGIN_SERVER }} \
--username ${{ secrets.ACR_USERNAME }} \
--password ${{ secrets.ACR_PASSWORD }}
helm push my-app-*.tgz oci://${{ secrets.ACR_LOGIN_SERVER }}/helm
Common Mistakes to Avoid
1. Hardcoding values in templates
# Bad
image: myregistry.azurecr.io/payment-service:3.4.1
# Good
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
2. Not quoting string values
# Bad — YAML interprets "true" as boolean
value: {{ .Values.env.ENABLE_FEATURE }}
# Good — always quote env var values
value: {{ .Values.env.ENABLE_FEATURE | quote }}
3. Missing resource limits
Always define resources.requests and resources.limits. Without them, a single pod can consume all node resources.
4. Not using helm upgrade --install
Use helm upgrade --install instead of separate install/upgrade commands. It installs if the release doesn't exist and upgrades if it does — idempotent and CI/CD-friendly.
5. Giant monolithic charts
If your chart has 20+ templates, break it into sub-charts. Each microservice should have its own chart, with a parent "umbrella" chart if needed.
Helm isn't just templating — it's version control for your Kubernetes deployments. When a release breaks, helm rollback gets you back to a known-good state in seconds. When you need to deploy to 5 environments, value overrides keep your templates DRY.
Start with helm create, customize the templates, set up your environment-specific values files, and you'll never manually edit 6 YAML files for a deployment again.
What's your Helm workflow? Umbrella charts or per-service charts? Share your approach.
Follow me for more Kubernetes and DevOps content.
Top comments (0)