Helm Charts Demystified: Kubernetes Templating Without the Pain
Master Helm charts: chart anatomy, Go templating, values overrides, public registries, release management, lifecycle hooks. Plus when to use Kustomize instead.
Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

The Friday We Deleted the Staging Database (Because of YAML)
Picture the scene: six Kubernetes environments (dev, staging, qa-1, qa-2, demo, prod), a 400-line Deployment manifest, and a sed script named apply-env.sh that rewrites the image tag, the replica count, the ingress host, the database URL, the Redis URL, and the resource limits before piping into kubectl apply. One Friday the sed pattern matched one character too many and the staging PVC name came out mangled. Kubernetes dutifully provisioned a new PVC, the StatefulSet rescheduled, and the old PVC was garbage-collected ninety seconds later. Bye-bye staging Postgres.
The root cause was not sed. The root cause was that we were treating Kubernetes manifests as text instead of as a configurable package with a proper values API. That Monday we replaced the entire apply-env.sh pipeline with a single Helm chart and per-environment values.yaml files. The ingress host, image tag, replica count, and resource limits became named keys instead of string offsets. No sed. No grep. No weekend data recovery.
This guide is a working engineer's walkthrough of Helm. Not every function reference -- just the pieces I use in production: chart anatomy, enough Go templating to be dangerous, release management, the failure modes that bite teams the first year, and an honest comparison with Kustomize. If you have ever copy-pasted a Kubernetes YAML, mutated three values, and prayed, this guide closes that loop.
Chart Anatomy: What's Inside
my-chart/
Chart.yaml # Chart metadata (name, version, dependencies)
values.yaml # Default configuration values
charts/ # Dependency charts (subcharts)
templates/ # Kubernetes manifest templates
deployment.yaml
service.yaml
ingress.yaml
_helpers.tpl # Reusable template snippets
NOTES.txt # Post-install instructions
.helmignore # Files to exclude from packaging
Chart.yaml
The metadata file that defines your chart's identity and dependencies:
apiVersion: v2
name: my-web-app
description: A web application with API and worker
type: application
version: 1.2.0 # Chart version (semver)
appVersion: "3.4.1" # Application version
dependencies:
- name: postgresql
version: "13.x"
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
values.yaml
The default configuration that users can override. This is where Helm's power lives -- one file controls everything:
replicaCount: 2
image:
repository: my-app
tag: "3.4.1"
pullPolicy: IfNotPresent
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
ingress:
enabled: true
className: nginx
hosts:
- host: myapp.example.com
paths:
- path: /
pathType: Prefix
postgresql:
enabled: true
auth:
database: myapp
Go Templating: The Basics You Need
Helm templates use Go's text/template package. The syntax feels odd if you come from Jinja2 or Handlebars, but the core patterns are simple:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-chart.fullname" . }}
labels:
{{- include "my-chart.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "my-chart.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "my-chart.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: {{ .Values.service.port }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- if .Values.env }}
env:
{{- range .Values.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
{{- end }}
Essential Template Functions
| Function | Purpose | Example |
|---|---|---|
{{ .Values.x }} | Access values.yaml | {{ .Values.replicaCount }} |
{{ .Release.Name }} | Release name | my-app-prod |
{{ .Chart.Name }} | Chart name | my-web-app |
include | Render a named template | {{ include "chart.labels" . }} |
toYaml | Convert value to YAML | {{ toYaml .Values.resources }} |
nindent | Newline + indent | {{ x | nindent 4 }} |
quote | Wrap in quotes | {{ .Values.tag | quote }} |
default | Fallback value | {{ .Values.port | default 8080 }} |
if/else | Conditional rendering | {{- if .Values.ingress.enabled }} |
range | Loop over lists/maps | {{- range .Values.env }} |
The _helpers.tpl File
This file defines reusable named templates (partials) that other templates reference with include. The convention is to define labels, names, and selectors here:
# templates/_helpers.tpl
{{- define "my-chart.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "my-chart.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
Working with Helm: Step by Step
- Create a chart:
helm create my-appgenerates the scaffold - Edit templates and values to match your application
- Lint:
helm lint my-app/checks for errors - Dry run:
helm template my-app ./my-app/ -f prod-values.yamlrenders YAML without deploying - Install:
helm install my-app ./my-app/ -f prod-values.yaml -n production - Upgrade:
helm upgrade my-app ./my-app/ -f prod-values.yaml -n production - Rollback:
helm rollback my-app 1 -n productionreverts to revision 1
# Override values at install time
helm install my-app ./my-app/ \
--set replicaCount=3 \
--set image.tag=v2.1.0 \
-f production-values.yaml \
-n production
# View all releases
helm list -n production
# Check release history
helm history my-app -n production
Pro tip: Always use
helm templateto render your manifests locally before deploying. Pipe the output tokubectl diffto see exactly what will change in the cluster. Never deploy blind -- especially to production.
Public Chart Registries
Don't reinvent the wheel. Most common infrastructure (databases, message queues, monitoring tools) has a well-maintained Helm chart:
| Registry | URL | Notable Charts |
|---|---|---|
| Bitnami | charts.bitnami.com/bitnami | PostgreSQL, Redis, Kafka, MongoDB |
| ingress-nginx | kubernetes.github.io/ingress-nginx | NGINX Ingress Controller |
| Jetstack | charts.jetstack.io | cert-manager |
| Prometheus Community | prometheus-community.github.io/helm-charts | kube-prometheus-stack |
| Artifact Hub | artifacthub.io | Central search for all public charts |
# Add a repository and install a chart
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm install my-postgres bitnami/postgresql -f postgres-values.yaml
Release Management and Lifecycle Hooks
Helm tracks every install and upgrade as a numbered revision. You can roll back to any previous revision, and Helm stores the full manifest for each one.
Lifecycle Hooks
Hooks let you run Jobs or other resources at specific points in the release lifecycle:
# Run a database migration before upgrading
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "my-chart.fullname" . }}-migrate
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-1"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
template:
spec:
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["node", "migrate.js"]
restartPolicy: Never
Available Hook Points
pre-install/post-install-- run before/after the first installpre-upgrade/post-upgrade-- run before/after upgradespre-delete/post-delete-- run before/after uninstallpre-rollback/post-rollback-- run before/after rollbacktest-- run withhelm test
Failure Modes That Bite First-Year Helm Users
Immutable Field Updates
Try to change a StatefulSet's serviceName or a Deployment's selector.matchLabels in a values override and Helm will happily template new YAML -- then Kubernetes rejects the helm upgrade with field is immutable. The release lands in FAILED state and blocks further upgrades until you helm rollback or delete the resource. Audit selectors before bumping chart versions, and treat them like database schema migrations.
Indentation Drift in Templates
The classic {{ toYaml .Values.resources }} bug: no nindent, the YAML gets spliced with the wrong indentation, and the rendered manifest is syntactically valid but semantically wrong. Kubernetes silently applies a Deployment with no resource limits, which then gets OOMKilled at 3am. Always pipe multi-line YAML through nindent, and run helm template ... | kubeconform in CI to catch structural mistakes.
Hooks Running at the Wrong Time
Database migrations in a pre-upgrade hook work -- until the migration fails. The Job is left behind, the upgrade is marked failed, and a retry collides with the still-running Job. Set "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded and give migrations an aggressive timeout. Bonus hazard: post-install hooks cannot depend on ingress being reachable from outside the cluster because ingress controllers reconcile asynchronously.
Release Name Collisions in CI
Helm release names are namespace-scoped. A PR pipeline that deploys to preview-<pr-number> works until two PRs use the same number across different branches, or a stale preview hangs around. Use helm upgrade --install --atomic --cleanup-on-fail and garbage-collect previews older than N days with a scheduled Job.
Helm 2 vs Helm 3: Key Differences
| Feature | Helm 2 | Helm 3 |
|---|---|---|
| Tiller (server component) | Required | Removed |
| Release storage | ConfigMaps via Tiller | Secrets in release namespace |
| Security model | Tiller had cluster-admin | Uses kubeconfig RBAC |
| Three-way merge | No (two-way) | Yes (detects manual changes) |
| Chart API version | v1 | v2 |
| CRD handling | crd-install hook | crds/ directory |
Watch out: Helm 2 reached end of life in November 2020. If you're still running Tiller, migrate immediately. Tiller runs with cluster-admin permissions by default, which is a serious security risk. Helm 3's removal of Tiller was the single most important change in the project's history.
When to Use Kustomize Instead
Kustomize is Helm's main alternative, built into kubectl. It uses a template-free approach -- you write plain YAML and layer patches on top.
Helm vs Kustomize
| Aspect | Helm | Kustomize |
|---|---|---|
| Approach | Templating (Go templates) | Patching (overlays on base YAML) |
| Packaging | Charts with versioning | Directories with kustomization.yaml |
| Dependencies | Built-in subchart system | Manual or remote bases |
| Release tracking | Built-in revisions + rollback | None (relies on GitOps) |
| Learning curve | Go templates + Helm CLI | Patches + strategic merge |
| Best for | Packaging reusable applications | Environment-specific overlays |
Many teams use both: Helm for installing third-party charts (ingress-nginx, cert-manager, Prometheus) and Kustomize for their own application manifests. They're not mutually exclusive.
Pricing and Tooling Costs
| Tool | Cost | Purpose |
|---|---|---|
| Helm CLI | Free (OSS) | Chart management and deployment |
| Artifact Hub | Free | Public chart discovery |
| ChartMuseum | Free (self-hosted) | Private chart repository |
| AWS ECR (OCI charts) | $0.10/GB/month | Store charts as OCI artifacts |
| Harbor | Free (OSS) | Chart + image registry with scanning |
| Helmfile | Free (OSS) | Declarative multi-chart deployments |
Frequently Asked Questions
What is the difference between helm install and helm upgrade?
helm install creates a new release -- it fails if the release name already exists. helm upgrade updates an existing release to a new chart version or values. In CI/CD pipelines, use helm upgrade --install which installs if the release doesn't exist and upgrades if it does. This is the idempotent pattern most teams use.
How do I pass secrets to a Helm chart?
Never put secrets in values.yaml or commit them to Git. Use --set with CI/CD pipeline secrets (helm install --set db.password=$DB_PASSWORD), or integrate with external secret management (Sealed Secrets, External Secrets Operator, SOPS-encrypted value files). The helm-secrets plugin decrypts SOPS files at deploy time.
Can I use Helm with GitOps tools like ArgoCD or Flux?
Yes. ArgoCD natively understands Helm charts -- point it at a chart in a Git repo with a values file, and it renders and syncs the manifests. Flux uses HelmRelease CRDs for the same purpose. Both support automatic upgrades when chart versions change. This is the recommended way to use Helm in production.
What are OCI-based Helm charts?
Since Helm 3.8, charts can be stored as OCI (Open Container Initiative) artifacts in any OCI-compatible registry -- Docker Hub, ECR, GCR, GitHub Container Registry. This means you use helm push and helm pull with the same registries that store your container images. No separate chart repository needed.
How do I test Helm charts?
Use helm lint for syntax checks, helm template to render and inspect output, and helm test to run test Pods defined with the test hook annotation. For more thorough testing, tools like chart-testing (ct) from the Helm project validate chart changes in CI, and kubeval or kubeconform validate the rendered YAML against Kubernetes schemas.
Should new projects use Helm or Kustomize?
Use Helm if you're packaging an application for others to install (internal platform team, open-source project) or if you need release tracking and rollback. Use Kustomize if you're deploying your own services and prefer plain YAML with environment overlays. Many teams use Helm for third-party charts and Kustomize for their own apps.
What is Helmfile and when should I use it?
Helmfile is a declarative tool for managing multiple Helm releases. Instead of running helm install commands in scripts, you define all releases in a helmfile.yaml with their values, dependencies, and ordering. Use it when you need to deploy 5+ Helm releases together as a coherent environment. It's the "docker-compose for Helm."
Conclusion
Helm solves a real problem: managing Kubernetes YAML at scale. Start by using public charts for infrastructure (databases, ingress, monitoring), then create your own charts when you find yourself copying manifests between environments. Keep templates simple -- if your _helpers.tpl is longer than your actual templates, you've gone too far. Use helm template and helm lint in CI, store charts as OCI artifacts, and let ArgoCD or Flux handle the actual deployment.
Written by
Abhishek Patel
Infrastructure engineer with 10+ years building production systems on AWS, GCP, and bare metal. Writes practical guides on cloud architecture, containers, networking, and Linux for developers who want to understand how things actually work under the hood.
Related Articles
Multi-Cluster Kubernetes: Argo CD ApplicationSet Patterns
When 10+ clusters or 50+ services break hand-written GitOps. ApplicationSet's four generators (cluster list, Git directory, PR, cluster decision), real production patterns (env promotion, per-tenant, multi-region failover, preview envs), and the sharp edges (template debugging, cascading mistakes, RBAC).
11 min read
ContainersKubernetes GPU Scheduling: DRA, KAI Scheduler, MIG
Dynamic Resource Allocation replaced device plugins for GPU claims in Kubernetes 1.34. KAI Scheduler adds gang scheduling and queues. MIG slices H100s into 7 isolated tenants. Full production setup with the NVIDIA GPU Operator, topology-aware training, and when to use MIG vs MPS vs time-slicing.
17 min read
CI/CDProgressive Delivery with Argo Rollouts: Canary + Analysis
Argo Rollouts replaces Kubernetes Deployments with a CRD that does weighted canary, metric-gated analysis, and automatic rollback. Production recipe, Prometheus AnalysisTemplates, and a side-by-side with Flagger.
15 min read
Enjoyed this article?
Get more like this in your inbox. No spam, unsubscribe anytime.