Helm is the Kubernetes community’s answer to “how do we stop copy-pasting YAML for every environment?” It’s the right answer. But Helm charts have a failure mode that’s specific to their nature as a templating system: they’re easy to start, painful to maintain, and downright hostile to read once they’ve accumulated six months of special cases.
Here’s how to write Helm charts that don’t become problems.
Chart Structure
Start with a clean structure:
my-app/
├── Chart.yaml # Chart metadata
├── values.yaml # Default values with documentation
├── values.schema.json # JSON schema validation for values
├── templates/
│ ├── _helpers.tpl # Named templates and helpers
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ ├── hpa.yaml
│ ├── pdb.yaml
│ ├── serviceaccount.yaml
│ ├── secret.yaml
│ └── tests/
│ └── test-connection.yaml
└── .helmignore
The _helpers.tpl File
Every chart should have a well-structured _helpers.tpl that defines your naming conventions:
{{/*
Expand the name of the chart.
*/}}
{{- define "my-app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ include "my-app.chart" . }}
{{ include "my-app.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
These patterns come from helm create scaffolding and are the community standard. Follow them. Your users know what to expect.
Values Documentation
values.yaml is documentation. Treat it that way:
# -- Number of replicas for the deployment
replicaCount: 1
image:
# -- Container image repository
repository: myapp
# -- Container image pull policy
pullPolicy: IfNotPresent
# -- Container image tag; defaults to the chart's appVersion
tag: ""
# -- Resource limits and requests for the main container
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
# -- Configure liveness probe
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 10
# -- Configure readiness probe
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 10
periodSeconds: 5
service:
# -- Kubernetes service type
type: ClusterIP
# -- Service port
port: 80
ingress:
# -- Enable ingress resource
enabled: false
# -- Ingress class name
className: nginx
annotations: {}
hosts:
- host: chart-example.local
paths:
- path: /
pathType: Prefix
tls: []
# -- Autoscaling configuration
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
Use the # -- doc-comment format (recognized by helm-docs for automatic documentation generation).
Values Schema Validation
Add values.schema.json to catch misconfiguration early:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1
},
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string" },
"pullPolicy": {
"type": "string",
"enum": ["Always", "IfNotPresent", "Never"]
},
"tag": { "type": "string" }
}
}
}
}
Helm validates values against this schema before rendering. This catches replicaCount: "three" before it causes a confusing API server error.
Template Anti-Patterns
Anti-pattern: Duplicating Blocks
Bad:
# In deployment.yaml
env:
- name: DB_HOST
value: {{ .Values.database.host }}
- name: DB_PORT
value: {{ .Values.database.port | quote }}
# In migration-job.yaml (exact same block)
env:
- name: DB_HOST
value: {{ .Values.database.host }}
- name: DB_PORT
value: {{ .Values.database.port | quote }}
Good—define once in _helpers.tpl:
{{- define "my-app.dbEnv" -}}
- name: DB_HOST
value: {{ .Values.database.host }}
- name: DB_PORT
value: {{ .Values.database.port | quote }}
{{- end }}
Then use it:
env:
{{- include "my-app.dbEnv" . | nindent 2 }}
Anti-pattern: Hardcoded Namespace
Never hardcode namespace in a chart template. Use .Release.Namespace:
# Bad
namespace: production
# Good
namespace: {{ .Release.Namespace }}
Anti-pattern: Ignoring the NOTES.txt
templates/NOTES.txt is shown to users after helm install. Use it:
1. Get the application URL by running:
{{- if .Values.ingress.enabled }}
http{{ if .Values.ingress.tls }}s{{ end }}://{{ (first .Values.ingress.hosts).host }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "my-app.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- end }}
Conditional Resources
Use enabled flags consistently:
# values.yaml
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
podDisruptionBudget:
enabled: true
minAvailable: 1
# hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "my-app.fullname" . }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "my-app.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
{{- end }}
Helm Hooks for Database Migrations
Database migrations that run before your application starts are a natural fit for Helm hooks:
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "my-app.fullname" . }}-migrate
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
template:
spec:
containers:
- name: migrate
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
command: ["./migrate", "up"]
restartPolicy: Never
The hook-delete-policy: hook-succeeded means the job is cleaned up automatically after success, keeping your cluster tidy.
Testing Your Charts
Run helm lint before every commit:
helm lint ./my-app
helm lint ./my-app --strict # Fail on warnings too
Use helm template to render and review the output:
helm template my-release ./my-app -f values-production.yaml | kubectl apply --dry-run=client -f -
Add chart tests:
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: {{ include "my-app.fullname" . }}-test-connection
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "my-app.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never
Run with helm test my-release. Helm hooks make this run after deployment and clean up on success.
The Golden Rule
A Helm chart is a public API. Once people are using your chart with their own values.yaml, changing a value’s path or type is a breaking change. Design your values structure thoughtfully from the start. Follow the community patterns (see Artifact Hub for examples). And document everything—your future self, and everyone else deploying your chart, will thank you.