Most engineers using Helm fall into one of two camps: those who helm install charts they found on Artifact Hub and tweak values.yaml until something works, and those who actually understand what’s happening under the hood. The gap between the two is enormous — not in terms of difficulty, but in terms of what you can actually do.
If you’ve ever copy-pasted a Deployment manifest from one project to another, changed 5 field names, and called it a Helm chart — this article is an intervention. We’re going to build a chart from the ground up, cover the template engine properly, wire up lifecycle hooks for real-world scenarios, and then extract shared logic into a library chart so you stop repeating yourself.
The official GitHub: https://github.com/helm/helm
Helm docs: https://helm.sh/docs/
The Chart Skeleton
When you run helm create myapp, Helm scaffolds a chart for you. It’s fine as a starting point, but it’s bloated — ingress.yaml, serviceaccount.yaml, HPA, notes, the works. Most of it doesn’t apply to your app.
Start clean instead:
mkdir myapp && cd myapp
mkdir -p templates
touch Chart.yaml values.yaml templates/_helpers.tpl
Chart.yaml is the chart’s identity card:
# Chart.yaml
apiVersion: v2 # v2 = Helm 3 only (use v1 if you need Helm 2 compat — you don't)
name: myapp
description: My application chart
type: application # or "library" — more on that later
version: 0.1.0 # chart version, follows semver
appVersion: "1.0.0" # the version of the app being packaged — cosmetic, no technical effect
values.yaml is where you define your defaults. Treat it as the public API of your chart — every knob a user might need should live here, with sane defaults.
# values.yaml
replicaCount: 2
image:
repository: nginx
tag: "1.25"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
env: {}
# env:
# DATABASE_URL: postgres://user:pass@db:5432/app
# DEBUG: "false"
Go Templates: The Real Helm
Every file in templates/ is processed through Go’s text/template engine with Helm-specific extensions. This is where most people either click or give up.
The template context (the . dot) is a map containing:
.Values— yourvalues.yamlmerged with any--setflags.Release— metadata about the release (.Release.Name,.Release.Namespace, etc.).Chart— contents ofChart.yaml.Files— access to non-template files in the chart directory.Capabilities— cluster API versions and Kubernetes version
Here’s a real Deployment template, not a toy:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.service.port }}
protocol: TCP
{{- if .Values.env }}
env:
{{- range $key, $val := .Values.env }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
Notice {{- ... -}} — the dashes trim whitespace before and after the action. Miss them and you’ll get YAML with blank lines that make helm lint complain and your reviewers twitch.
nindent 4 is indent 4 with a leading newline prepended — essential when you’re rendering multi-line blocks inside an existing indented structure.
Named Templates: Your Best Friend
The _helpers.tpl file (the underscore prefix means Helm won’t render it as a manifest) is where you put reusable template snippets. Think of it as a Go package full of functions.
# templates/_helpers.tpl
{{/*
Expand the name of the chart.
*/}}
{{- define "myapp.name" -}}
{{- .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a fully qualified app name.
If release name already contains chart name, avoid duplication.
*/}}
{{- define "myapp.fullname" -}}
{{- if contains .Chart.Name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{/*
Common labels — attach to every resource for proper selector behavior
*/}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "myapp.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels — these must NOT change after initial deploy
Labels used in selector are immutable on Deployments and Services
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Call them anywhere with {{ include "myapp.labels" . }}. The second argument (.) is the context you’re passing in — in 99% of cases it’s the root dot.
Gotcha: template vs include. The old {{ template "name" . }} action can’t be piped. {{ include "name" . | nindent 4 }} works because include returns a string. Always use include in Helm charts.
Conditional Logic and Range
These are the two template constructs you’ll use constantly.
# Conditionally render a Service
{{- if .Values.service.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "myapp.fullname" . }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "myapp.selectorLabels" . | nindent 4 }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.port }}
{{- end }}
Range over a list to create multiple resources from a single values block:
# values.yaml
configMaps:
app-config:
DB_HOST: postgres
DB_PORT: "5432"
feature-flags:
NEW_DASHBOARD: "true"
# templates/configmaps.yaml
{{- range $name, $data := .Values.configMaps }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "myapp.fullname" $ }}-{{ $name }}
namespace: {{ $.Release.Namespace }}
data:
{{- range $key, $val := $data }}
{{ $key }}: {{ $val | quote }}
{{- end }}
{{- end }}
Notice $ vs . inside range — inside the loop, . is rebound to the current element. Use $ to reach back to the root context (.Release, .Values, etc.).
Gotcha: This is one of the most common bugs I see. Someone writes .Release.Name inside a range loop, gets empty output or a confusing error, and spends 30 minutes debugging it. Use $ inside range. Always.
Hooks: Running Jobs at the Right Moment
Helm hooks let you execute Pods (typically Jobs) at specific points in the release lifecycle. The main ones:
| Hook | When it runs |
|---|---|
pre-install |
After templates render, before any resources are created |
post-install |
After all resources are created |
pre-upgrade |
Before upgrading resources |
post-upgrade |
After upgrade completes |
pre-delete |
Before deleting resources |
post-delete |
After deletion |
pre-rollback |
Before rollback |
test |
When helm test is called |
The classic use case: run database migrations before deploying a new app version.
# templates/hooks/pre-upgrade-migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-migrate
namespace: {{ .Release.Namespace }}
annotations:
# This annotation is what makes it a hook
"helm.sh/hook": pre-upgrade,pre-install
# Weight controls order when multiple hooks of the same type exist
"helm.sh/hook-weight": "-5"
# Delete the job after it completes — don't leave junk behind
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["python", "manage.py", "migrate"]
env:
{{- range $key, $val := .Values.env }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
The helm.sh/hook-delete-policy annotation deserves attention. Three options:
before-hook-creation— delete the old hook resource when a new release comes in (safe, clean)hook-succeeded— delete after the hook Job completes successfullyhook-failed— delete even if it fails (use this if you want to clean up failed jobs automatically, at the cost of losing logs)
Combining before-hook-creation,hook-succeeded is the sensible default — keeps the Job around long enough to inspect if it fails, but cleans up on success.
Gotcha: Hooks are NOT included in helm status output and are NOT tracked as part of the release’s resource set. If your migration Job fails and you didn’t set a delete policy, re-running helm upgrade will fail with "resource already exists" because Helm tries to create the Job again. Always set a hook delete policy.
Production-ready note: For migrations that can take a long time, set activeDeadlineSeconds on the Job spec. A migration that hangs forever will block your entire CI/CD pipeline — set a ceiling, alert on it.
Testing Your Chart
Helm has a built-in test mechanism. Write a test Pod that validates your deployment:
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: {{ include "myapp.fullname" . }}-test-connection
namespace: {{ .Release.Namespace }}
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": hook-succeeded
spec:
restartPolicy: Never
containers:
- name: wget
image: busybox
command: ['wget']
args: ['--spider', '{{ include "myapp.fullname" . }}:{{ .Values.service.port }}']
Run it with helm test <release-name>. It’s not a substitute for proper integration tests, but it’s a fast sanity check that your service is actually reachable after deploy.
For local development, helm template is your bread and butter — it renders all templates to stdout without hitting a cluster:
# Render with overrides
helm template myrelease ./myapp --set replicaCount=3 --set image.tag=v2.0.0
# Check for syntax errors
helm lint ./myapp
# Dry-run against a real cluster (validates against cluster API)
helm install --dry-run --debug myrelease ./myapp
Library Charts: DRY at the Chart Level
Once you have more than two or three charts in your organization, you’ll notice the same _helpers.tpl snippets duplicated everywhere. Same label logic, same affinity blocks, same pod security context defaults. Library charts solve this.
A library chart is a chart with type: library in Chart.yaml. It cannot be installed directly — it exists only to be consumed by other charts as a dependency.
# myorg-common/Chart.yaml
apiVersion: v2
name: myorg-common
description: Shared templates for myorg charts
type: library # <-- key difference
version: 0.1.0
Define your shared templates in myorg-common/templates/_common.tpl:
# myorg-common/templates/_common.tpl
{{/*
Standard myorg labels — includes team and cost-center for chargeback
*/}}
{{- define "myorg.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/managed-by: {{ .Release.Service }}
myorg.com/team: {{ .Values.team | default "platform" }}
myorg.com/cost-center: {{ .Values.costCenter | default "engineering" }}
{{- end }}
{{/*
Standard pod security context — baseline security for all workloads
*/}}
{{- define "myorg.podSecurityContext" -}}
runAsNonRoot: true
runAsUser: 1000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
{{- end }}
{{/*
Standard container security context
*/}}
{{- define "myorg.containerSecurityContext" -}}
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
{{- end }}
{{/*
Resource defaults if none provided — prevents pods from consuming unbounded resources
*/}}
{{- define "myorg.defaultResources" -}}
{{- if .Values.resources }}
{{- toYaml .Values.resources | nindent 0 }}
{{- else }}
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
{{- end }}
{{- end }}
Now in your application chart, declare the library as a dependency:
# myapp/Chart.yaml
apiVersion: v2
name: myapp
type: application
version: 0.2.0
appVersion: "1.0.0"
dependencies:
- name: myorg-common
version: "0.1.0"
repository: "oci://registry.myorg.com/helm" # or a local path for development
For local development, use a local path repository:
dependencies:
- name: myorg-common
version: "0.1.0"
repository: "file://../myorg-common"
Run helm dependency update ./myapp — this downloads dependencies into myapp/charts/.
Now consume the library templates in your chart:
# myapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}
labels:
{{- include "myorg.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
{{- include "myorg.labels" . | nindent 8 }}
spec:
securityContext:
{{- include "myorg.podSecurityContext" . | nindent 8 }}
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
securityContext:
{{- include "myorg.containerSecurityContext" . | nindent 12 }}
resources:
{{- include "myorg.defaultResources" . | nindent 12 }}
Every chart in your org now inherits the same security baseline, the same label structure, and the same resource defaults. When you need to update the pod security context requirements across the fleet — change it in one place.
Gotcha: Library chart templates are only available if the library is listed in dependencies AND helm dependency update has been run. If someone clones your chart repo and skips helm dep update, they’ll get confusing "function not defined" errors. Add this to your chart README and your CI pipeline.
Packaging and Publishing
Once your chart is ready, package it:
helm package ./myapp
# Produces myapp-0.1.0.tgz
Push to an OCI registry (the modern approach — forget old HTTP chart repos):
helm push myapp-0.1.0.tgz oci://registry.myorg.com/helm
Install from the registry:
helm install myrelease oci://registry.myorg.com/helm/myapp --version 0.1.0
OCI registries (Docker Hub, ECR, GitHub Container Registry, Harbor) all support Helm charts natively. The old helm repo add + index.yaml approach is effectively legacy at this point.
Gotchas Summary
A few more that’ll save you hours of head-scratching:
Immutable selector labels. Once a Deployment is created with specific selector.matchLabels, you cannot change them. If you rename your chart and the myapp.selectorLabels output changes, helm upgrade will fail with a confusing "field is immutable" error. The only fix is helm uninstall + helm install. Design your selector labels to never change.
toYaml with nindent. When using {{- toYaml .Values.someBlock | nindent 4 }}, the block from values.yaml must be valid YAML that makes sense at that indentation level. A common mistake is defining a block in values without a top-level key, then nindenting it into a position where the structure breaks.
Hook resources are not upgraded. Hooks create resources outside the normal Helm resource tracking. If you change a hook template (say, the migration Job’s image), the old Job isn’t automatically replaced — only the helm.sh/hook-delete-policy controls cleanup. Always include before-hook-creation in your delete policy.
Empty values and the default function. {{ .Values.someKey | default "fallback" }} is your friend, but it only triggers on empty string, zero, nil, or false. If someone passes --set someKey="", default kicks in. If they pass --set someKey=0, default does NOT kick in for integer-typed values. Test edge cases in your values.
Chart versioning. version in Chart.yaml is the chart’s version, not the app’s. Bump it on every chart change, even if appVersion didn’t change. Helm uses the chart version for caching and dependency resolution — leaving it at 0.1.0 forever will cause you pain when you try to pin versions in production.
What to Do Next
A chart that follows these patterns — proper named templates, lifecycle hooks for database migrations, library charts for shared logic — is something you can actually maintain across a team and across years.
The natural next step is helm unittest (the community plugin, not the built-in helm test) for writing snapshot and assertion-based tests against your templates before you push. It catches the kind of regression where someone adds nindent 6 that should be nindent 4 and the YAML silently breaks a field.
After that, look at Helm schema validation — dropping a values.schema.json into your chart gives you type checking and required field validation on helm install/upgrade. It’s one of those things that feels optional until someone deploys with a typo in replicaCount and schedules 0 replicas on your production app.