Everything in this tutorial — templating, named helpers, dependencies, hooks, and versioning — converges here. You will build a production-grade Helm chart for a real-world web service: an API backend backed by Redis, with per-environment value files, a database migration hook, a readiness probe, a PodDisruptionBudget, and RBAC. By the end you will have a chart you can drop into a GitHub Actions pipeline and ship to any Kubernetes cluster without modification.
The application is taskflow-api: a stateless Node.js REST service that reads from Redis (managed externally — AWS ElastiCache in staging/prod, a Helm subchart in dev). It needs a Deployment, a Service, an Ingress, a ConfigMap, a Secret, a ServiceAccount, a PodDisruptionBudget, and an HPA. Every field that differs across environments is exposed as a chart value.
Step 1 — Scaffold and Chart.yaml
Start from the official scaffold and immediately edit Chart.yaml to declare the real metadata, Kubernetes version constraint, and the Redis subchart dependency:
helm create taskflow-api
cd taskflow-api
# Remove the boilerplate files we will replace entirely
rm -rf templates/* values.yaml charts/
mkdir charts
Edit Chart.yaml:
apiVersion: v2
name: taskflow-api
description: TaskFlow REST API — stateless Node.js service
type: application
version: 0.1.0 # chart version — bump on every chart change
appVersion: "1.0.0" # application image tag — updated by CI
kubeVersion: ">=1.28.0"
maintainers:
- name: platform-team
email: platform@example.com
dependencies:
- name: redis
version: "19.x.x"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: redis.enabled # disabled in staging/prod (use ElastiCache)
Chart version vs. appVersion:version tracks the chart itself — templating changes, new values, added objects. appVersion tracks the Docker image version of the application. In CI you update appVersion on every image push; version only when the chart structure changes. Keep them decoupled. Google and Spotify platform teams enforce this by having the build pipeline sed-replace only appVersion and bump version via a separate PR to the chart repo.
Step 2 — The Master values.yaml
Every environment-varying parameter lives here with safe, minimal defaults (single replica, small resources — fine for dev, overridden for staging/prod). Document every key with a comment; this file is the public interface of your chart.
Create templates/_helpers.tpl first — the naming helpers every other template will call:
{{/*
Expand the name of the chart.
*/}}
{{- define "taskflow-api.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "taskflow-api.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "taskflow-api.labels" -}}
helm.sh/chart: {{ include "taskflow-api.name" . }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ include "taskflow-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "taskflow-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "taskflow-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Redis URL — internal subchart or external host
*/}}
{{- define "taskflow-api.redisUrl" -}}
{{- if .Values.redis.enabled -}}
redis://{{ .Release.Name }}-redis-master:6379
{{- else -}}
redis://{{ required "externalRedis.host required when redis.enabled=false" .Values.externalRedis.host }}:{{ .Values.externalRedis.port }}
{{- end }}
{{- end }}
Now create the core manifests. The Deployment is the most complex template — note the security context, the readiness/liveness probes, and how the Redis URL is injected via an environment variable sourced from the ConfigMap:
ConfigMap checksum annotation: The checksum/config annotation forces a rolling restart whenever the ConfigMap changes. Without it, a helm upgrade that only updates a config value will not restart the pods — they keep running with stale config. This single line, used by every major Helm chart in the ecosystem (cert-manager, Prometheus, ingress-nginx), prevents a class of "why didn't my config change take effect?" incidents.
Create the remaining templates — ConfigMap, Service, Ingress, ServiceAccount, PDB, and HPA:
Never use --set for more than one or two scalar values in production. Instead, maintain a values file per environment in a separate deploy/ directory (or a dedicated GitOps repo). The base values.yaml holds safe dev defaults; environment files only override what differs:
The base values.yaml provides safe dev defaults; environment files override only what differs — the Helm engine deep-merges both to produce the final rendered manifests.
Step 5 — Pre-Upgrade Migration Hook
Database migrations must run before the new pods come up. A Helm pre-upgrade Job hook is the canonical pattern:
Production pitfall — hook-delete-policy: Always include hook-delete-policy: before-hook-creation,hook-succeeded. Without hook-succeeded, old migration Jobs accumulate in the namespace after each deploy. Without before-hook-creation, a failed Job blocks the next upgrade because Kubernetes refuses to create a Job with the same name. The Release.Revision suffix makes each Job name unique per revision, giving you a fresh object every time while the delete policy cleans up successes automatically.
Step 6 — Install and Verify All Environments
Use helm template locally first — zero cluster access needed — to verify that each environment file renders exactly what you expect. This is a mandatory step before any CI pipeline ships a chart:
Diff before you deploy: Install the helm-diff plugin (helm plugin install https://github.com/databus23/helm-diff) and run helm diff upgrade taskflow-prod . --values deploy/values-prod.yaml before every production upgrade. It prints a colour-coded diff of what will change in the cluster — an invaluable safety check that is standard practice on every production deployment at companies like Datadog, Stripe, and GitHub.
This chart encodes six months of hard-won production knowledge into a single deployable unit: it prevents configuration drift across environments, enforces security contexts by default, handles zero-downtime deploys via PDB and rolling update strategy, runs migrations atomically before traffic shifts, and gives you a full audit trail via Helm release history. Every pattern here — the checksum annotation, the conditional PDB, the IRSA annotation path, the per-environment values files — mirrors exactly how mature platform teams package applications at big-tech scale.