Kubernetes Helm Charts Done Right
Best practices for writing maintainable Helm charts - templating, values, dependencies, and testing.
Table of Contents
Helm is the de facto package manager for Kubernetes. Here’s how to write charts that don’t become maintenance nightmares.
Chart Structure #
mychart/
├── Chart.yaml # Chart metadata
├── values.yaml # Default configuration
├── values-prod.yaml # Environment overrides
├── templates/
│ ├── _helpers.tpl # Template helpers
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ ├── secret.yaml
│ └── hpa.yaml
└── charts/ # Dependencies
Chart.yaml #
apiVersion: v2
name: myapp
description: My application
type: application
version: 1.0.0 # Chart version
appVersion: "2.3.1" # Application version
dependencies:
- name: postgresql
version: "12.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
Smart values.yaml #
Design values for flexibility without complexity:
# values.yaml
replicaCount: 2
image:
repository: myapp
tag: "" # Defaults to Chart.appVersion
pullPolicy: IfNotPresent
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 80
ingress:
enabled: false
className: nginx
hosts:
- host: app.example.com
paths:
- path: /
pathType: Prefix
tls: []
env: {}
# env:
# LOG_LEVEL: info
# CACHE_TTL: "300"
secrets: {}
# secrets:
# DATABASE_URL: postgres://...
postgresql:
enabled: true
auth:
database: myapp
username: myapp
Template Helpers #
DRY up your templates with _helpers.tpl:
{{/* templates/_helpers.tpl */}}
{{/*
Expand the name of the chart.
*/}}
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a fully qualified app name.
*/}}
{{- define "myapp.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 "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
{{ include "myapp.selectorLabels" . }}
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Image tag - defaults to appVersion
*/}}
{{- define "myapp.imageTag" -}}
{{- .Values.image.tag | default .Chart.AppVersion }}
{{- end }}
Deployment Template #
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ include "myapp.imageTag" . }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
envFrom:
- configMapRef:
name: {{ include "myapp.fullname" . }}
{{- if .Values.secrets }}
- secretRef:
name: {{ include "myapp.fullname" . }}
{{- end }}
Conditional Resources #
Only create resources when needed:
# templates/hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "myapp.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
Environment-Specific Values #
# values-prod.yaml
replicaCount: 3
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
ingress:
enabled: true
hosts:
- host: api.mycompany.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: api-tls
hosts:
- api.mycompany.com
Install with:
helm upgrade --install myapp ./mychart \
-f values-prod.yaml \
--set image.tag=v2.3.1
Testing Charts #
Lint #
helm lint ./mychart
Template Rendering #
# Check what will be generated
helm template myapp ./mychart -f values-prod.yaml
Helm Test #
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "myapp.fullname" . }}-test"
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "myapp.fullname" . }}:80/healthz']
restartPolicy: Never
Run tests:
helm test myapp
Common Patterns #
ConfigMap Reload #
Force deployment restart when config changes:
# In deployment annotations
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
Required Values #
Fail fast if required values are missing:
{{- required "image.repository is required" .Values.image.repository }}
Default with Lookup #
Use existing secret if available:
{{- $existingSecret := lookup "v1" "Secret" .Release.Namespace "my-secret" }}
{{- if $existingSecret }}
# Use existing secret
{{- else }}
# Create new secret
{{- end }}
Anti-Patterns to Avoid #
1. Hardcoded Values in Templates #
# Bad
image: myapp:v1.0.0
# Good
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
2. Monolithic Templates #
Split large templates into separate files.
3. Ignoring Schema Validation #
Add values.schema.json to validate values:
{
"$schema": "http://json-schema.org/schema#",
"type": "object",
"required": ["image"],
"properties": {
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": {"type": "string"}
}
}
}
}
Key Takeaways #
- Use
_helpers.tplfor reusable template functions - Design values.yaml for environment flexibility
- Include checksums to trigger restarts on config changes
- Test charts with
helm lintandhelm template - Use schema validation for values
Well-structured Helm charts make deployments predictable and maintainable.