Why Helm Still Matters
Kubernetes YAML is verbose, repetitive, and error-prone at scale. When you're managing 15 microservices across 4 environments, raw manifests become unmaintainable. Helm solves this by giving you templating, packaging, versioning, and rollback — the package manager Kubernetes needed.
But most teams use Helm at a tutorial level: helm install and pray. Production Helm requires understanding chart structure, values inheritance, dependency management, and proper release lifecycle. Let's go deeper.
Chart Structure That Scales
my-service/
├── Chart.yaml # Chart metadata and dependencies
├── Chart.lock # Dependency lock file
├── values.yaml # Default values
├── values-staging.yaml # Environment overrides
├── values-production.yaml # Environment overrides
├── templates/
│ ├── _helpers.tpl # Template helpers and named templates
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── hpa.yaml
│ ├── ingress.yaml
│ ├── serviceaccount.yaml
│ ├── configmap.yaml
│ ├── pdb.yaml # Pod Disruption Budget
│ ├── networkpolicy.yaml
│ ├── servicemonitor.yaml # Prometheus scraping
│ └── tests/
│ └── test-connection.yaml
├── charts/ # Dependency charts
└── .helmignore
Chart.yaml
apiVersion: v2
name: api-service
description: Order processing API service
type: application
version: 1.4.2 # Chart version (bump on chart changes)
appVersion: "2.3.1" # Application version (what's deployed)
dependencies:
- name: postgresql
version: "13.2.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
- name: redis
version: "18.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled
Templating for Real Applications
The _helpers.tpl Foundation
{{/ templates/_helpers.tpl /}}
{{- define "api-service.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "api-service.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 }}
{{- define "api-service.labels" -}}
helm.sh/chart: {{ include "api-service.chart" . }}
app.kubernetes.io/name: {{ include "api-service.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/component: api
app.kubernetes.io/part-of: order-system
{{- end }}
{{- define "api-service.selectorLabels" -}}
app.kubernetes.io/name: {{ include "api-service.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "api-service.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
Production Deployment Template
{{/ templates/deployment.yaml /}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "api-service.fullname" . }}
labels:
{{- include "api-service.labels" . | nindent 4 }}
annotations:
{{- with .Values.deploymentAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: {{ .Values.strategy.maxSurge | default "25%" }}
maxUnavailable: {{ .Values.strategy.maxUnavailable | default 0 }}
selector:
matchLabels:
{{- include "api-service.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "api-service.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "api-service.fullname" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort | default 8080 }}
protocol: TCP
- name: metrics
containerPort: {{ .Values.metrics.port | default 9090 }}
protocol: TCP
env:
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
envFrom:
{{- if .Values.existingSecret }}
- secretRef:
name: {{ .Values.existingSecret }}
{{- end }}
- configMapRef:
name: {{ include "api-service.fullname" . }}
startupProbe:
httpGet:
path: {{ .Values.health.startup.path | default "/health/ready" }}
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: {{ .Values.health.readiness.path | default "/health/ready" }}
port: http
periodSeconds: {{ .Values.health.readiness.periodSeconds | default 5 }}
failureThreshold: {{ .Values.health.readiness.failureThreshold | default 3 }}
livenessProbe:
httpGet:
path: {{ .Values.health.liveness.path | default "/health/live" }}
port: http
periodSeconds: {{ .Values.health.liveness.periodSeconds | default 10 }}
failureThreshold: {{ .Values.health.liveness.failureThreshold | default 3 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds | default 60 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
{{- include "api-service.selectorLabels" . | nindent 14 }}
Multi-Environment Values
Default values.yaml
# values.yaml — sane defaults, overridden per environment
replicaCount: 2
image:
repository: myregistry/api-service
pullPolicy: IfNotPresent
tag: "" # Defaults to Chart.appVersion
service:
type: ClusterIP
port: 80
targetPort: 8080
ingress:
enabled: false
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
env:
LOG_LEVEL: "info"
SERVICE_NAME: "api-service"
health:
readiness:
path: /health/ready
periodSeconds: 5
failureThreshold: 3
liveness:
path: /health/live
periodSeconds: 10
failureThreshold: 3
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
postgresql:
enabled: false
redis:
enabled: false
values-production.yaml
# values-production.yaml — production overrides
replicaCount: 6
image:
tag: "2.3.1"
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/rate-limit-rps: "100"
hosts:
- host: api.mycompany.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: api-tls
hosts:
- api.mycompany.com
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 2Gi
autoscaling:
enabled: true
minReplicas: 6
maxReplicas: 20
targetCPUUtilizationPercentage: 60
env:
LOG_LEVEL: "warn"
ENABLE_METRICS: "true"
DB_POOL_SIZE: "20"
existingSecret: api-database-secret
postgresql:
enabled: false # Using managed RDS in production
values-staging.yaml
# values-staging.yaml
replicaCount: 2
image:
tag: "2.3.1-rc.1"
ingress:
enabled: true
className: nginx
hosts:
- host: api.staging.internal
paths:
- path: /
pathType: Prefix
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
env:
LOG_LEVEL: "debug"
postgresql:
enabled: true # Run PostgreSQL in-cluster for staging
auth:
postgresPassword: staging-pass
database: orders
Deploying Per Environment
# Staging
helm upgrade --install api-service ./my-service \
--namespace staging \
--values ./my-service/values-staging.yaml \
--set image.tag="2.3.1-rc.2" \
--wait --timeout 5m
# Production
helm upgrade --install api-service ./my-service \
--namespace production \
--values ./my-service/values-production.yaml \
--set image.tag="2.3.1" \
--wait --timeout 10m \
--atomic # Auto-rollback on failure
Helm Hooks for Lifecycle Events
Database Migration Before Deployment
{{/ templates/migration-job.yaml /}}
{{- if .Values.migrations.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "api-service.fullname" . }}-migrate-{{ .Release.Revision }}
labels:
{{- include "api-service.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
backoffLimit: 3
activeDeadlineSeconds: 300
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command: ["./migrate", "up"]
envFrom:
- secretRef:
name: {{ .Values.existingSecret }}
{{- end }}
Smoke Test After Deployment
{{/ templates/tests/test-connection.yaml /}}
apiVersion: v1
kind: Pod
metadata:
name: {{ include "api-service.fullname" . }}-test
labels:
{{- include "api-service.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": before-hook-creation
spec:
restartPolicy: Never
containers:
- name: test
image: curlimages/curl:8.5.0
command:
- sh
- -c
- |
echo "Testing health endpoint..."
curl -sf http://{{ include "api-service.fullname" . }}:{{ .Values.service.port }}/health/ready || exit 1
echo "Testing API response..."
curl -sf http://{{ include "api-service.fullname" . }}:{{ .Values.service.port }}/api/v1/status || exit 1
echo "All tests passed!"
Versioning Strategy
Follow semantic versioning for charts:
- Patch (1.4.1 → 1.4.2): Template fixes, default value changes, docs
- Minor (1.4.2 → 1.5.0): New optional features, new templates, non-breaking
- Major (1.5.0 → 2.0.0): Breaking changes to values schema, removed templates
# In CI/CD pipeline
# Lint before publishing
helm lint ./my-service --values ./my-service/values-production.yaml
# Template and validate
helm template api-service ./my-service \
--values ./my-service/values-production.yaml | \
kubectl apply --dry-run=server -f -
# Package and push to OCI registry
helm package ./my-service
helm push my-service-1.4.2.tgz oci://myregistry.azurecr.io/helm-charts
Rollbacks Done Right
# View release history
helm history api-service -n production
# Rollback to previous revision
helm rollback api-service 0 -n production --wait
# Rollback to specific revision
helm rollback api-service 12 -n production --wait --timeout 5m
# Check what changed between revisions
helm diff revision api-service 12 13 # requires helm-diff plugin
The --atomic flag during upgrades is your safety net:
helm upgrade --install api-service ./my-service \
--namespace production \
--values ./my-service/values-production.yaml \
--atomic \
--timeout 10m
If any pod fails to become ready within the timeout, Helm automatically rolls back to the previous release. No manual intervention needed.
Best Practices Checklist
--atomic in production — auto-rollback on failure.Chart.lock and commit it.checksum/config annotation — triggers pod restart when ConfigMap changes.helm lint, helm template, kubeval, kubectl --dry-run=server..helmignore — exclude tests, CI files, and documentation from packaged charts.values.yaml should have a comment explaining its purpose.Final Thought
Helm is infrastructure-as-code for Kubernetes. Treat your charts with the same rigor as your application code: version them, test them, review them in PRs, and automate their deployment. The teams that get Helm right deploy confidently across environments without YAML drift or configuration surprises. The teams that don't end up with a pile of ad-hoc kubectl apply scripts that nobody understands.
---
Frequently Asked Questions
What is Helm and why should I use it for Kubernetes?
Helm is a package manager for Kubernetes that bundles related manifests into charts with templating and versioning. It simplifies deploying complex applications, manages releases with upgrade/rollback capabilities, and allows customization through values files. Use Helm when deploying multi-resource applications that need reproducible installations across environments.
How do I roll back a Helm release?
Run helm rollback <release-name> <revision-number> to revert to a previous release version. Use helm history <release-name> to see all revisions with their status and timestamps. Helm stores release history in Kubernetes secrets by default, so rollbacks are fast — they reapply the previous manifest set.
What is the difference between Helm 2 and Helm 3?
Helm 3 removed Tiller (the server-side component that required cluster-admin privileges), making Helm client-only and more secure. It also uses 3-way merge for upgrades (comparing live state, old chart, and new chart), stores releases as secrets instead of ConfigMaps, and supports library charts. Always use Helm 3 for new projects.
How do I manage Helm values across environments?
Create a base values.yaml with defaults and environment-specific override files like values-staging.yaml and values-production.yaml. Deploy with helm install -f values.yaml -f values-production.yaml. Use Helmfile for managing multiple charts and environments declaratively, or ArgoCD with ApplicationSets for GitOps-based value management.
How do I create a custom Helm chart?
Run helm create mychart to scaffold a standard chart structure with templates, values, and helpers. Customize the templates in templates/, define configurable values in values.yaml, and add dependencies in Chart.yaml. Test locally with helm template mychart to render manifests and helm lint to check for issues before deploying.
---
Related Resources
- YAML/JSON Converter — YAML/JSON converter for Helm values
- Kubernetes Pod Troubleshooting — Pod troubleshooting guide
- kubectl Cheatsheet — kubectl commands reference
- Kubernetes ConfigMaps and Secrets — Managing Helm chart configurations
- Kubernetes Resource Limits — Setting resource limits in Helm values