Helm Chart Development From Scratch: Templates, Hooks, and Library Charts

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 — your values.yaml merged with any --set flags
  • .Release — metadata about the release (.Release.Name, .Release.Namespace, etc.)
  • .Chart — contents of Chart.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 successfully
  • hook-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.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646