Helm — Advanced Patterns
Beyond basics — patterns that trip up experienced engineers and aren't well-captured in training data.
Docs: https://helm.sh/docs/ | Sprig: https://masterminds.github.io/sprig/
Library Charts
Shared template logic across multiple charts. Not deployable on their own.
# Chart.yaml
apiVersion: v2
name: mylib
type: library # Not "application"
version: 0.1.0
Merge Utility Pattern
The canonical way to let consumers override library-defined resources:
# mylib/templates/_util.tpl
{{- define "mylib.util.merge" -}}
{{- $top := first . -}}
{{- $overrides := fromYaml (include (index . 1) $top) | default (dict ) -}}
{{- $base := fromYaml (include (index . 2) $top) | default (dict ) -}}
{{- toYaml (merge $overrides $base) -}}
{{- end -}}
Consumer chart uses it to override specific fields while inheriting defaults:
# mychart/templates/deployment.yaml
{{- include "mylib.util.merge" (list . "mychart.deployment.overrides" "mylib.deployment") }}
# mychart/templates/_overrides.tpl
{{- define "mychart.deployment.overrides" -}}
spec:
replicas: {{ .Values.replicaCount }}
{{- end -}}
Shared Label/Annotation Templates
# mylib/templates/_labels.tpl
{{- define "mylib.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 }}
{{- end -}}
{{- define "mylib.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
Values Schema Validation
values.schema.json validates values at install/upgrade/lint/template time. Catches config errors before they hit the cluster.
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["image", "service"],
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1,
"maximum": 50
},
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string" },
"pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
},
"additionalProperties": false
},
"resources": {
"type": "object",
"properties": {
"requests": { "$ref": "#/definitions/resourceBlock" },
"limits": { "$ref": "#/definitions/resourceBlock" }
}
}
},
"definitions": {
"resourceBlock": {
"type": "object",
"properties": {
"cpu": { "type": "string", "pattern": "^[0-9]+m?$" },
"memory": { "type": "string", "pattern": "^[0-9]+(Mi|Gi)$" }
}
}
}
}
Generate from values files: helm plugin install https://github.com/losisin/helm-values-schema-json && helm schema -input values.yaml
The lookup Function
Query live cluster state during rendering. Returns empty dict in --dry-run=client, works with --dry-run=server.
# Only create Secret if it doesn't already exist (preserve generated passwords across upgrades)
{{- if not (lookup "v1" "Secret" .Release.Namespace "myapp-credentials") }}
apiVersion: v1
kind: Secret
metadata:
name: myapp-credentials
type: Opaque
data:
password: {{ randAlphaNum 32 | b64enc | quote }}
{{- end }}
# Iterate over existing namespaces to deploy NetworkPolicies
{{- range (lookup "v1" "Namespace" "" "").items }}
{{- if hasKey .metadata.labels "app.kubernetes.io/managed-by" }}
# ... generate NetworkPolicy for {{ .metadata.name }}
{{- end }}
{{- end }}
Gotcha: lookup returns nil in dry-run client mode. Guard with {{- if .Release.IsInstall }} or use --dry-run=server.
Template Pitfalls & Solutions
Nil Map Panics
# WRONG — panics if .Values.ingress is nil
{{- if .Values.ingress.enabled }}
# CORRECT — safe navigation
{{- if ((.Values.ingress).enabled) }}
# Also works with dig (Sprig)
{{- if (dig "ingress" "enabled" false .Values) }}
Whitespace Control
# Produces clean YAML without extra blank lines
{{- if .Values.podAnnotations }}
annotations:
{{- toYaml .Values.podAnnotations | nindent 8 }}
{{- end }}
- on {{- trims left whitespace; -}} trims right. Without them, blank lines accumulate.
Type Coercion
# Values from --set are always strings. "true" != true
# Use quote + explicit type handling
{{- if eq (toString .Values.debug) "true" }}
# Or in schema, coerce with: {"type": ["boolean", "string"]}
Range Scope
# WRONG — $ is the global scope, . changes inside range
{{- range .Values.hosts }}
host: {{ . }}
service: {{ $.Values.serviceName }} # Use $ to access root
{{- end }}
CRD Management
Two approaches, both with tradeoffs:
crds/ Directory (Helm 3)
- CRDs in
crds/are installed once on first install - Never updated or deleted by Helm (by design)
- Simple but no lifecycle management
Hook-Based CRDs
# templates/crds.yaml
{{- if .Values.installCRDs }}
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
name: myresources.example.com
spec:
# ...
{{- end }}
Gives upgrade control but risks deleting CRDs (and all CRs) on uninstall. Add "helm.sh/resource-policy": keep annotation.
Post-Renderers
Mutate rendered manifests before apply. Useful for injecting sidecars, labels, or policy annotations without modifying chart templates.
⚠️ Helm 4 breaking change: Post-renderers must be installed Helm plugins — passing an executable path (--post-renderer ./script.sh) no longer works. Use --post-renderer <plugin-name> with a properly installed plugin.
Helm 3 (executable path — still works in Helm 3.x):
helm upgrade --install myapp ./mychart --post-renderer ./inject-labels.sh
#!/bin/bash
# inject-labels.sh — add labels via kustomize
cat > /tmp/kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- rendered.yaml
commonLabels:
company.io/cost-center: platform
EOF
cat <&0 > /tmp/rendered.yaml
kustomize build /tmp
Helm 4 (plugin name — required in Helm 4, also works in Helm 3.14+):
# Install a post-renderer plugin, then reference it by name
helm plugin install https://github.com/example/helm-post-renderer-kustomize
helm upgrade --install myapp ./mychart --post-renderer kustomize
See Helm 4 plugin system (HIP-0026) and example plugins for WebAssembly-based post-renderer plugins.
Multi-Tenant / Subchart Patterns
Global Values
# Parent values.yaml
global:
imageRegistry: 123456789.dkr.ecr.us-east-1.amazonaws.com
storageClass: longhorn
# Accessible in subcharts as .Values.global.imageRegistry
Conditional Subcharts
# Chart.yaml
dependencies:
- name: postgresql
version: "15.x"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: postgresql.enabled
import-values:
- child: primary.service
parent: database
import-values pulls subchart values into parent scope — avoids deeply nested postgresql.primary.service.port paths.
Per-Environment Overrides
# Layered values: base → environment → secrets
helm upgrade --install myapp ./mychart \
-f values.yaml \
-f values-production.yaml \
-f secrets-production.yaml.dec \
--set image.tag=${GIT_SHA}
Secrets Handling
Charts should never contain secrets. Integration patterns:
# External Secrets Operator — reference in templates
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ include "myapp.fullname" . }}-db
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: {{ include "myapp.fullname" . }}-db
data:
- secretKey: password
remoteRef:
key: {{ .Values.externalSecrets.dbPasswordKey }}
# SOPS-encrypted values (decrypt before helm)
# values-secrets.yaml.enc → decrypt with sops
helm secrets upgrade myapp ./mychart -f values.yaml -f values-secrets.yaml
Testing with helm-unittest
# tests/deployment_test.yaml
suite: deployment
templates:
- templates/deployment.yaml
tests:
- it: should not render when disabled
set:
enabled: false
asserts:
- hasDocuments:
count: 0
- it: should set resource limits from values
set:
resources:
limits:
memory: 1Gi
asserts:
- equal:
path: spec.template.spec.containers[0].resources.limits.memory
value: 1Gi
- it: should fail without required values
set:
image.repository: ""
asserts:
- failedTemplate: {}
helm plugin install https://github.com/helm-unittest/helm-unittest
helm unittest ./mychart --color
Helm 4 CLI Flag Renames
Helm 4 (released November 2025) renamed two common flags. The old names still work but emit deprecation warnings — update scripts and CI pipelines:
| Old flag (Helm 3) | New flag (Helm 4) | Notes |
|---|---|---|
--atomic |
--rollback-on-failure |
Auto-rollback on failed install/upgrade |
--force |
--force-replace |
Delete and re-create resources on conflict |
# Helm 4 — use new flag names
helm upgrade --install myapp ./mychart --rollback-on-failure --timeout 10m
helm upgrade myapp ./mychart --force-replace
Debugging
# Render one template with debug output
helm template myapp ./mychart -s templates/deployment.yaml --debug 2>&1
# Diff what would change (requires helm-diff plugin)
helm diff upgrade myapp ./mychart -f values-production.yaml
# Validate against cluster without applying
helm upgrade --install myapp ./mychart --dry-run=server --debug
# Check computed values after all overlays
helm get values myapp -n production -a # -a = all (computed)
Common Advanced Errors
| Error | Cause | Fix |
|---|---|---|
nil pointer evaluating interface {} |
Accessing nested key on nil parent | Use dig or ((.Values.x).y) safe access |
schema validation failed |
values.schema.json rejects input | Run helm lint with your values files to see which field fails |
| CRDs not updated on upgrade | crds/ directory is install-only |
Move CRDs to templates with pre-upgrade hooks |
lookup returns empty in CI |
Client-side dry-run has no cluster | Use --dry-run=server or guard with {{- if not .Release.IsInstall }} |
| Post-renderer output invalid | Script must read stdin, write stdout | Ensure script outputs valid YAML manifests to stdout |
| Subchart values ignored | Wrong nesting in parent values.yaml | Values must be under subchart name key: postgresql.auth.password |
References
troubleshooting.md— Install and upgrade failures, template rendering, release management, and repository issues
Cross-References
- argocd — GitOps deployment of Helm releases via ArgoCD Applications
- crossplane — Helm provider for deploying charts as Crossplane managed resources
- external-secrets — Inject secrets into Helm releases without embedding them in values
- cert-manager — TLS certificate management commonly deployed via Helm
- kueue — Job queueing — Helm charts for ML workloads often need Kueue integration
- operator-sdk — Building operators that use Helm charts as the reconciliation engine
- github-actions — CI/CD for Helm chart linting, packaging, and publishing
- docker-buildx — Build container images referenced by Helm charts
- prometheus-grafana — kube-prometheus-stack deployed via Helm
- gpu-operator — GPU Operator deployed and configured via Helm