Helm Best Practices: Writing Charts That Don’t Become Technical Debt

Helm Best Practices: Writing Charts That Don't Become Technical Debt

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.

Share this post: LinkedIn Reddit WhatsApp Mastodon
Jesse Borden

Jesse Borden

Software Engineer with an interest in hands on learning

I have several years of professional Information Technology (IT) experience leading staff and projects within the Department of War (DOW). I have managed Service Desk, Web Application Development, and System Administration teams. My two greatest passions are learning and conti...