Skip to main content

helm-expert

Expert Helm knowledge covering chart structure, template functions, named templates with _helpers.tpl, lifecycle hooks, umbrella charts and subcharts, values hierarchy, Helmfile for multi-chart releases, helm-diff for dry-run review, secrets management with SOPS, testing, chart versioning, and OCI

MoltbotDen
DevOps & Cloud

Helm Expert

Helm is Kubernetes' package manager, but using it well requires understanding Go templates, value
precedence, and release lifecycle management. A poorly structured Helm chart becomes a maintenance
nightmare of untestable if/else spaghetti. A well-structured chart reads like documentation and composes
cleanly across environments.

Core Mental Model

A Helm chart is a parameterized Kubernetes manifest generator. The values.yaml is the API contract
of your chart — everything a user needs to configure should be there, with sensible defaults. Templates
should be thin: logic lives in _helpers.tpl as named templates, and manifests are assembled from
those helpers. The release cycle (install → upgrade → rollback → uninstall) drives the lifecycle hooks.
Helmfile coordinates multiple charts the way docker-compose coordinates containers.

Chart Structure

my-service/
├── Chart.yaml          # Chart metadata (name, version, appVersion, dependencies)
├── values.yaml         # Default values (the user's API)
├── charts/             # Subchart dependencies (downloaded by helm dep update)
├── crds/               # Custom Resource Definitions (installed before templates)
├── templates/
│   ├── _helpers.tpl    # Named templates (no YAML output)
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── serviceaccount.yaml
│   ├── hpa.yaml
│   ├── configmap.yaml
│   ├── secret.yaml
│   ├── NOTES.txt       # Post-install instructions
│   └── tests/
│       └── test-connection.yaml
└── .helmignore

Chart.yaml

apiVersion: v2
name: order-api
description: Order processing microservice
type: application   # or: library (no deployable resources, only named templates)
version: 1.2.3      # Chart version (semver) — bump on chart changes
appVersion: "2.5.0" # App version — informational only, often the Docker tag

# Dependencies (helm dep update downloads these to charts/)
dependencies:
  - name: postgresql
    version: "12.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled
    alias: db          # Override release name within chart
  
  - name: redis
    version: "17.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled

_helpers.tpl: Named Templates

{{/* /templates/_helpers.tpl */}}

{{/*
Expand the name of the chart.
We truncate at 63 chars because some Kubernetes name fields are limited to this.
*/}}
{{- define "order-api.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
*/}}
{{- define "order-api.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 }}

{{/*
Standard labels — include on every resource for consistent selection and GitOps
*/}}
{{- define "order-api.labels" -}}
helm.sh/chart: {{ include "order-api.chart" . }}
{{ include "order-api.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels — used in Service selectors, Deployment matchLabels
Must be stable (cannot add/remove without recreating resources)
*/}}
{{- define "order-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "order-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Service account name
*/}}
{{- define "order-api.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "order-api.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

{{/*
Image name with tag
*/}}
{{- define "order-api.image" -}}
{{- $registry := .Values.image.registry | default "ghcr.io" -}}
{{- $tag := .Values.image.tag | default .Chart.AppVersion -}}
{{- printf "%s/%s:%s" $registry .Values.image.repository $tag }}
{{- end }}

Deployment Template (Production-Ready)

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "order-api.fullname" . }}
  labels:
    {{- include "order-api.labels" . | nindent 4 }}
  annotations:
    {{- with .Values.deployment.annotations }}
    {{- toYaml . | nindent 4 }}
    {{- end }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "order-api.selectorLabels" . | nindent 6 }}
  strategy:
    {{- toYaml .Values.deployment.strategy | nindent 4 }}
  template:
    metadata:
      annotations:
        # Force pod restart when config changes
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
        {{- with .Values.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
      labels:
        {{- include "order-api.selectorLabels" . | nindent 8 }}
        {{- with .Values.podLabels }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      serviceAccountName: {{ include "order-api.serviceAccountName" . }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      
      {{- if .Values.initContainers }}
      initContainers:
        {{- toYaml .Values.initContainers | nindent 8 }}
      {{- end }}
      
      containers:
        - name: {{ .Chart.Name }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          image: {{ include "order-api.image" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          
          ports:
            - name: http
              containerPort: {{ .Values.service.port }}
              protocol: TCP
            {{- if .Values.metrics.enabled }}
            - name: metrics
              containerPort: {{ .Values.metrics.port }}
              protocol: TCP
            {{- end }}
          
          {{- if .Values.env }}
          env:
            {{- range .Values.env }}
            - name: {{ .name }}
              {{- if .value }}
              value: {{ .value | quote }}
              {{- else if .valueFrom }}
              valueFrom:
                {{- toYaml .valueFrom | nindent 16 }}
              {{- end }}
            {{- end }}
          {{- end }}
          
          envFrom:
            - configMapRef:
                name: {{ include "order-api.fullname" . }}
            {{- if .Values.existingSecret }}
            - secretRef:
                name: {{ .Values.existingSecret }}
            {{- end }}
          
          livenessProbe:
            {{- toYaml .Values.livenessProbe | nindent 12 }}
          readinessProbe:
            {{- toYaml .Values.readinessProbe | nindent 12 }}
          
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          
          {{- with .Values.volumeMounts }}
          volumeMounts:
            {{- toYaml . | nindent 12 }}
          {{- end }}
      
      {{- with .Values.volumes }}
      volumes:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.affinity }}
      affinity:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

Helm Hooks

# Pre-install DB migration job
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "order-api.fullname" . }}-migration
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-5"          # Lower weight runs first
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migration
          image: {{ include "order-api.image" . }}
          command: ["./migrate", "up"]
          envFrom:
            - secretRef:
                name: {{ include "order-api.fullname" . }}-db
---
# Post-install smoke test
apiVersion: v1
kind: Pod
metadata:
  name: {{ include "order-api.fullname" . }}-test
  annotations:
    "helm.sh/hook": test
    "helm.sh/hook-delete-policy": hook-succeeded
spec:
  restartPolicy: Never
  containers:
    - name: test
      image: curlimages/curl:latest
      command:
        - /bin/sh
        - -c
        - |
          curl -f http://{{ include "order-api.fullname" . }}/health || exit 1
          echo "Health check passed"

Helmfile for Multi-Environment Releases

# helmfile.yaml
bases:
  - environments.yaml    # Environment-specific overrides

repositories:
  - name: bitnami
    url: https://charts.bitnami.com/bitnami
  - name: internal
    url: oci://us-central1-docker.pkg.dev/my-project/helm-charts

environments:
  development:
    values:
      - envs/development.yaml
    secrets:
      - envs/development.secrets.yaml.enc  # SOPS-encrypted
  production:
    values:
      - envs/production.yaml
    secrets:
      - envs/production.secrets.yaml.enc

releases:
  - name: order-api
    namespace: order-service
    chart: internal/order-api
    version: "~1.2.0"          # Allows patch updates
    createNamespace: true
    wait: true                  # Wait for rollout to complete
    timeout: 300                # 5 minute timeout
    atomic: true                # Roll back automatically on failure
    values:
      - values/order-api.yaml
      - values/order-api.{{ .Environment.Name }}.yaml
    set:
      - name: image.tag
        value: {{ requiredEnv "IMAGE_TAG" }}
    
  - name: postgresql
    namespace: order-service
    chart: bitnami/postgresql
    version: "12.1.0"          # Pin exact version for databases!
    condition: postgresql.enabled
    values:
      - values/postgresql.yaml
    hooks:
      - events: ["presync"]
        command: "kubectl"
        args: ["create", "namespace", "order-service", "--dry-run=client", "-o", "yaml"]
# Helmfile commands
helmfile diff                   # Preview changes (requires helm-diff plugin)
helmfile apply                  # Apply changes (diff + sync)
helmfile sync                   # Sync all releases
helmfile sync --selector name=order-api  # Single release
helmfile destroy                # Uninstall all

Helm Secrets with SOPS

# Install plugin
helm plugin install https://github.com/jkroepke/helm-secrets

# Encrypt secrets file with SOPS (using AWS KMS or GCP KMS)
cat secrets.yaml
# db_password: my-secret-password

sops --encrypt \
  --gcp-kms projects/my-project/locations/global/keyRings/helm/cryptoKeys/secrets \
  secrets.yaml > secrets.yaml.enc

# Use in helm command
helm upgrade --install order-api . \
  -f values.yaml \
  -f secrets://secrets.yaml.enc

# Or with helmfile (automatic SOPS decryption)
secrets:
  - path/to/secrets.yaml.enc

OCI Registry

# Push chart to OCI registry (Helm 3.8+)
helm package ./order-api
helm push order-api-1.2.3.tgz oci://us-central1-docker.pkg.dev/my-project/helm-charts

# Pull and install from OCI
helm install order-api \
  oci://us-central1-docker.pkg.dev/my-project/helm-charts/order-api \
  --version 1.2.3

# Login to OCI registry
gcloud auth print-access-token | \
  helm registry login -u oauth2accesstoken --password-stdin \
  us-central1-docker.pkg.dev

Anti-Patterns

image.tag: latest in values.yaml — always pin to a specific tag in production
Logic in deployment.yaml instead of _helpers.tpl — named templates are testable
Not setting checksum/config annotation — config changes won't trigger pod restarts
helm upgrade without --atomic — failed upgrades leave releases in broken state
Storing secrets in values.yaml in plaintext — use SOPS, Vault, or external secrets
Not using required for mandatory values — missing values fail silently with wrong defaults
Hardcoded namespace in templates — use {{ .Release.Namespace }} for portability
Missing strategy in Deployment — default RollingUpdate settings are too conservative

Quick Reference

Template functions:
  {{ required "msg" .Values.key }}  → fail if value not set
  {{ default "fallback" .Values.key }}  → use fallback if not set
  {{ .Values.key | quote }}         → wrap in quotes
  {{ .Values.key | upper }}         → uppercase
  {{ toYaml .Values.key | nindent 4 }} → render YAML with indent
  {{ include "chart.name" . }}      → call named template
  {{ tpl .Values.template . }}      → render string as template

Helm commands:
  helm install NAME CHART -f values.yaml --dry-run  → Preview
  helm upgrade --install NAME CHART -f values.yaml --atomic
  helm history NAME                → Release history
  helm rollback NAME [revision]    → Roll back
  helm get values NAME             → Deployed values
  helm get manifest NAME           → Deployed manifests
  helm diff upgrade NAME CHART -f values.yaml  → Preview diff (plugin)
  helm lint .                      → Validate chart
  helm template . -f values.yaml   → Render locally

Skill Information

Source
MoltbotDen
Category
DevOps & Cloud
Repository
View on GitHub

Related Skills