Helm Chart Development#
Writing your own Helm charts turns static YAML into reusable, configurable packages. The learning curve is in Go’s template syntax and Helm’s conventions, but once you internalize the patterns, chart development is fast.
Chart Structure#
Create a new chart scaffold:
helm create my-appThis generates:
my-app/
Chart.yaml # chart metadata (name, version, dependencies)
values.yaml # default configuration values
charts/ # dependency charts (populated by helm dependency update)
templates/ # Kubernetes manifest templates
deployment.yaml
service.yaml
ingress.yaml
serviceaccount.yaml
hpa.yaml
NOTES.txt # post-install instructions (printed after helm install)
_helpers.tpl # named template definitions
tests/
test-connection.yaml # helm test podChart.yaml#
The Chart.yaml defines your chart’s identity and dependencies:
apiVersion: v2
name: my-app
description: A Helm chart for my application
type: application
version: 0.1.0 # chart version (bump this on chart changes)
appVersion: "1.16.0" # application version (informational)
dependencies:
- name: postgresql
version: "~16.0"
repository: oci://registry-1.docker.io/bitnamicharts
condition: postgresql.enabledAfter editing dependencies, run:
helm dependency update ./my-app
# Downloads charts into charts/ directory and generates Chart.lockNamed Templates and _helpers.tpl#
The _helpers.tpl file defines reusable template blocks. Files starting with _ are never rendered as Kubernetes manifests – they only hold template definitions.
# templates/_helpers.tpl
{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "my-app.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}Use include (not template) to call named templates because include returns a string you can pipe through functions:
# CORRECT: include returns a string, can be piped
labels:
{{- include "my-app.labels" . | nindent 4 }}
# WRONG: template writes directly to output, cannot be piped
labels:
{{ template "my-app.labels" . }} # indentation will breakEssential Template Functions#
These are the functions you will use constantly:
# nindent: add newline + indent (critical for YAML structure)
metadata:
labels:
{{- include "my-app.labels" . | nindent 4 }}
# toYaml: convert a values structure to YAML
resources:
{{- toYaml .Values.resources | nindent 2 }}
# default: fallback value
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
# quote: wrap in quotes (necessary for strings that look like numbers)
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum | quote }}
# tpl: render a string as a template (for values that contain template expressions)
env:
- name: DATABASE_URL
value: {{ tpl .Values.databaseUrl . }}
# values.yaml: databaseUrl: "postgresql://{{ .Release.Name }}-db:5432/app"
# required: fail with a message if value is missing
{{ required "image.repository is required" .Values.image.repository }}Conditionals and Loops#
# Conditional blocks
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "my-app.fullname" . }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "my-app.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}Note the $ in $.Values inside the range loop. Inside range, the dot (.) is rebound to the current item. Use $ to access the root context.
The with block rebinds . to its argument if the argument is non-empty, and skips the block if it is empty:
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 2 }}
{{- end }}Whitespace Control#
The {{- and -}} markers trim whitespace. Without them, your YAML will have blank lines and broken indentation:
# Without whitespace control: produces blank lines
{{ if .Values.debug }}
debug: true
{{ end }}
# With whitespace control: clean output
{{- if .Values.debug }}
debug: true
{{- end }}Linting and Validation#
# Lint checks for common errors (missing required fields, bad YAML)
helm lint ./my-app
helm lint ./my-app -f values/production.yaml
# Template rendering to verify output
helm template test-release ./my-app -f values/production.yaml
# Render a single template
helm template test-release ./my-app -s templates/deployment.yaml
# Validate against cluster API (catches CRD issues, API version problems)
helm template test-release ./my-app | kubectl apply --dry-run=server -f -Chart Testing with helm test#
Define test pods in templates/tests/. Helm runs them with helm test and reports pass/fail based on exit codes:
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "my-app.fullname" . }}-test-connection"
labels:
{{- include "my-app.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "my-app.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Neverhelm test my-release -n my-namespaceSubchart Values#
Pass values to dependency charts by nesting under the dependency name in values.yaml:
postgresql:
enabled: true
auth:
database: myapp
username: appuserThe condition: postgresql.enabled in Chart.yaml means the entire subchart is skipped when postgresql.enabled is false.
Debugging Tips#
When templates produce unexpected output, add a debug template that dumps all values:
# Show all computed values
helm template my-release ./my-app --debug 2>&1 | head -50
# Show what Helm sees for a specific value path
helm template my-release ./my-app --show-only templates/deployment.yamlCommon mistakes: forgetting nindent (YAML indentation errors), using template instead of include, referencing .Values inside a range or with block without $, and not quoting values that look like booleans or numbers.