Skip to main content

Kubernetes Helm Charts Done Right

Best practices for writing maintainable Helm charts - templating, values, dependencies, and testing.

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 #

  1. Use _helpers.tpl for reusable template functions
  2. Design values.yaml for environment flexibility
  3. Include checksums to trigger restarts on config changes
  4. Test charts with helm lint and helm template
  5. Use schema validation for values

Well-structured Helm charts make deployments predictable and maintainable.