The first time I had to deploy the same application to three environments — dev, staging, production — each with slightly different replica counts, resource limits, and hostnames, I understood exactly why Helm exists. Copy-pasting YAML and hand-editing values is fine for a weekend project; it’s a liability for anything real. Helm turns Kubernetes manifests into templated, versioned, parameterized packages, and this guide covers it from first principles through production patterns.
What Helm Actually Is
Helm is often described as “the package manager for Kubernetes,” which is accurate but understates what it does. A chart is a directory of templated YAML manifests plus metadata. A release is a specific instantiation of a chart with a specific set of values, tracked by Helm as a distinct object in the cluster. Helm 3 (the current major version) stores release state as Kubernetes Secrets in the target namespace — there’s no separate server-side component (Tiller) like there was in Helm 2.
Kubernetes Architecture Context
Helm doesn’t replace the Kubernetes API — it’s a client that renders templates into standard manifests and applies them via the same API server every kubectl apply goes through. The control plane (API server, etcd, scheduler, controller manager) has no special awareness of Helm; from Kubernetes’ perspective, a Helm-deployed Deployment is indistinguishable from a hand-applied one, except for the ownership annotations Helm adds for tracking.
Installing Helm
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod +x get_helm.sh
./get_helm.sh
helm version
Output:
version.BuildInfo{Version:"v3.15.0", GitCommit:"...", GoVersion:"go1.22.1"}
Anatomy of a Chart
helm create myapp
This scaffolds:
myapp/
Chart.yaml
values.yaml
charts/
templates/
deployment.yaml
service.yaml
ingress.yaml
_helpers.tpl
NOTES.txt
Chart.yaml holds metadata:
apiVersion: v2
name: myapp
description: A Helm chart for myapp
type: application
version: 0.1.0
appVersion: "1.0.0"
values.yaml holds the default configuration:
replicaCount: 2
image:
repository: registry.example.com/myapp
tag: "1.0.0"
pullPolicy: IfNotPresent
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
service:
type: ClusterIP
port: 80
ingress:
enabled: false
Templating
Templates in templates/deployment.yaml reference values with Go templating:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Chart.Name }}
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
resources:
{{- toYaml .Values.resources | nindent 12 }}
ports:
- containerPort: 8080
Render without installing, to sanity-check output:
helm template myapp ./myapp
Installing and Upgrading Releases
helm install myapp-prod ./myapp \
--namespace production --create-namespace \
--values values-production.yaml
helm list -n production
Output:
NAME NAMESPACE REVISION STATUS CHART APP VERSION
myapp-prod production 1 deployed myapp-0.1.0 1.0.0
Upgrading with a new image tag:
helm upgrade myapp-prod ./myapp \
--namespace production \
--set image.tag=1.1.0
Roll back if the upgrade misbehaves:
helm rollback myapp-prod 1 -n production
Values Files per Environment
# values-production.yaml
replicaCount: 6
image:
tag: "1.1.0"
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
ingress:
enabled: true
host: app.example.com
helm upgrade --install myapp-prod ./myapp -f values-production.yaml -n production
This is the core Helm workflow: one chart, many values files, one command per environment.
Dependencies and Subcharts
Charts can depend on other charts — a common pattern is bundling Redis or Postgres as a dependency:
# Chart.yaml
dependencies:
- name: redis
version: "18.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled
helm dependency update ./myapp
RBAC and ServiceAccounts via Helm
A chart commonly templates its own ServiceAccount and RBAC bindings:
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Chart.Name }}-sa
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ .Chart.Name }}-role
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
Helm in CI/CD
Helm slots directly into a pipeline stage:
helm upgrade --install myapp ./chart \
--namespace production \
--set image.tag=$BUILD_NUMBER \
--atomic --timeout 5m
--atomic automatically rolls back on failure, and --timeout prevents a pipeline from hanging indefinitely on a broken rollout — both are worth using by default in any automated deploy stage.
Monitoring Helm Releases
helm status myapp-prod -n production
helm history myapp-prod -n production
helm get values myapp-prod -n production
High Availability and Storage Considerations
Helm itself has no runtime component to make HA — it’s a client tool. The HA concerns live in the charts you deploy: setting replicaCount appropriately, using PodDisruptionBudget templates, and making sure any StatefulSet-based dependency (like a database chart) uses a proper StorageClass with replication rather than a single local volume.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ .Chart.Name }}-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: {{ .Chart.Name }}
Helm Hooks for Lifecycle Management
Real deployments often need something to happen before or after an install/upgrade — a database migration, a cache warm-up, a smoke test. Helm hooks handle this without leaving the declarative model:
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-db-migrate
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
template:
spec:
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["./migrate.sh"]
restartPolicy: Never
hook-weight controls ordering when multiple hooks exist at the same lifecycle point — lower numbers run first. hook-delete-policy determines cleanup: hook-succeeded removes the Job once it finishes successfully, keeping the namespace from filling up with completed migration Jobs across every upgrade. Without this, a team running weekly deploys ends up with dozens of stale Job objects that add noise to every kubectl get jobs output.
Testing Charts Before They Reach Production
helm lint catches structural problems early:
helm lint ./myapp
==> Linting ./myapp
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed
For a closer approximation of what will actually happen in the cluster, combine --dry-run with --debug:
helm install myapp-test ./myapp --dry-run --debug -n staging
This renders every template exactly as it would be applied, without touching the cluster — the single most useful habit for catching a broken {{ }} expression before it becomes a failed production upgrade. Helm also supports a dedicated test framework via the helm.sh/hook: test annotation, letting a chart ship its own post-install verification:
apiVersion: v1
kind: Pod
metadata:
name: {{ .Release.Name }}-test-connection
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ .Release.Name }}-{{ .Chart.Name }}:{{ .Values.service.port }}']
restartPolicy: Never
helm test myapp-prod -n production
Managing Secrets Alongside Charts
Helm’s values.yaml is not an appropriate place for actual secret material, since it’s typically committed to version control in full. Two common patterns fill the gap without breaking the “one command deploys everything” workflow:
- Helm Secrets / SOPS — values files encrypted at rest, decrypted transparently at deploy time by a Helm plugin.
- External Secrets Operator — the chart references a Kubernetes Secret that’s populated separately from an external vault (AWS Secrets Manager, HashiCorp Vault, etc.), so the chart itself never carries secret material at all:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ .Chart.Name }}-db-creds
spec:
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: {{ .Chart.Name }}-db-creds
data:
- secretKey: password
remoteRef:
key: prod/myapp/db-password
Repository Management and Distribution
For internal charts shared across teams, hosting a private chart repository (via OCI registries, which Helm 3 supports natively, or a dedicated tool like ChartMuseum) keeps versioned charts discoverable the same way container images are:
helm package ./myapp
helm push myapp-0.1.0.tgz oci://registry.example.com/charts
helm install myapp oci://registry.example.com/charts/myapp --version 0.1.0
OCI-based distribution has become the more common approach recently, since it reuses the same registry infrastructure teams already run for container images rather than standing up a separate chart-hosting service.
Library Charts for Shared Templates
When multiple charts across a team share common boilerplate — the same labels, the same probe configuration structure — a library chart lets that logic live in one place rather than being copy-pasted into every application chart’s templates directory:
# common/Chart.yaml
apiVersion: v2
name: common
type: library
version: 0.1.0
# common/templates/_deployment.tpl
{{- define "common.deployment" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
labels:
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/name: {{ .Chart.Name }}
spec:
replicas: {{ .Values.replicaCount }}
{{- end -}}
Consuming charts declare the library as a dependency and call the shared template rather than redefining it:
dependencies:
- name: common
version: "0.1.0"
repository: "file://../common"
{{ include "common.deployment" . }}
This is the same motivation as shared CI pipeline libraries — one canonical definition of “what a standard Deployment looks like at this organization,” updated in a single place rather than drifting slowly out of sync across a dozen separate application repositories.
Validating values.yaml with a JSON Schema
For charts consumed by multiple teams (or exposed as an internal self-service platform offering), a values.schema.json file lets Helm reject invalid configuration before it ever reaches the API server, rather than surfacing as a confusing downstream error:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["image", "replicaCount"],
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1,
"maximum": 20
},
"image": {
"type": "object",
"required": ["repository", "tag"],
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" }
}
}
}
}
helm install myapp ./myapp --set replicaCount=0
Error: values don't meet the specifications of the schema(s) in the following chart(s):
myapp:
- replicaCount: Must be greater than or equal to 1
This kind of upfront validation is particularly valuable once a chart moves from “something one team maintains for itself” to “something other teams are expected to configure and deploy independently” — the schema becomes a form of self-documenting contract, catching mistakes at the point someone makes them rather than several steps downstream.
Common Mistakes
- Hardcoding secrets directly into
values.yamland committing it — use--set-file, Sealed Secrets, or an external secrets operator instead. - Not pinning chart dependency versions, so a
helm dependency updatesilently pulls in a breaking change. - Skipping
helm template/--dry-runbefore applying to production. - Treating
helm upgradeas always safe without--atomic, then discovering a half-applied rollout at 2 a.m.
Summary
Helm doesn’t add any new capability Kubernetes doesn’t already have — everything it produces is standard manifests. What it adds is repeatability: one chart, versioned and parameterized, deployed consistently to as many environments as you need, with rollback built in. Once a team has more than one environment or more than one microservice, the templating and release-tracking Helm provides stops being a convenience and starts being close to a necessity.
References
- Helm documentation: https://helm.sh/docs/
- Helm chart best practices: https://helm.sh/docs/chart_best_practices/
- Artifact Hub (chart registry): https://artifacthub.io/
- Kubernetes documentation: https://kubernetes.io/docs/home/