Helm Patterns
Purpose
Develop, test, deploy, and operate Helm charts for Kubernetes with best practices for templating, dependency management, security, CI/CD integration, and advanced patterns.
Agent Protocol
Trigger
Exact user phrases: "helm", "helm chart", "helm template", "helm install", "helm upgrade", "helm rollback", "helmfile", "helm secrets", "helm values", "helm dependency", "helm unittest", "helm lint", "helm CRD", "chart museum".
Input Context
- Chart purpose (app, infrastructure, library).
- Kubernetes version and distribution.
- CI/CD system (GitHub Actions, GitLab CI, ArgoCD).
- Secrets management (SOPS, SealedSecrets, External Secrets, Vault).
- Multi-environment strategy.
Output Artifact
Helm chart files (Chart.yaml, templates/, values.yaml), helmfile configuration, or CI/CD config.
Response Format
Chart files, helmfile YAML, or CI/CD pipeline YAML. No preamble.
Completion Criteria
- Chart.yaml with correct metadata, version, appVersion, dependencies.
- values.yaml with all configurable parameters documented.
- Templates with proper indentation, helpers, and conditionals.
- Hooks for pre/post install/upgrade/delete operations.
- Unittests covering key template outputs.
- CI/CD pipeline with lint, unittest, package, push.
- Secrets management integrated (SOPS or SealedSecrets).
- Multi-environment values files (values-dev.yaml, values-prod.yaml).
Max Response Length
400 lines.
Quick Start
helm create my-chart → Edit values.yaml → Write templates in templates/ → Add dependencies in Chart.yaml → helm lint → helm template --debug → helm unittest → Package and push to registry → Deploy with helm upgrade --install.
Decision Tree: Chart Types
| Chart Type | Purpose | Dependencies | Versioning |
|---|---|---|---|
| Application Chart | Deploys an application | None or subcharts | Semantic, bump per release |
| Library Chart | Provides helpers/snippets | No templates, only _helpers.tpl | Semantic, shared across teams |
| Umbrella Chart | Composes multiple subcharts | Many subchart deps | Version matches release bundle |
| Infrastructure Chart | Deploys operators, CRDs | CRD charts or depends-on | Tied to operator version |
| OCI Chart | Stored in OCI registry | Same as regular | OCI tag equals chart version |
Core Workflow
Step 1: Chart Structure
my-chart/
├── Chart.yaml # Metadata + dependencies
├── values.yaml # Default values
├── values.schema.json # JSON Schema validation
├── charts/ # Subcharts (packed dependencies)
├── templates/
│ ├── _helpers.tpl # Template helpers (Go templates)
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ ├── secret.yaml
│ ├── hpa.yaml
│ ├── pdb.yaml
│ ├── servicemonitor.yaml
│ ├── tests/
│ │ └── test-connection.yaml
│ └── NOTES.txt # Post-install output
├── ci/ # CI test values
│ ├── default-values.yaml
│ └── with-ingress.yaml
└── tests/ # Unit tests (helm-unittest)
├── deployment_test.yaml
└── service_test.yaml
Step 2: Chart.yaml with Dependencies
apiVersion: v2
name: my-app
description: A production-grade web application
type: application
version: 1.2.3
appVersion: "2.5.0"
kubeVersion: ">=1.27.0-0"
home: https://github.com/myorg/my-app
sources:
- https://github.com/myorg/my-app
maintainers:
- name: DevOps Team
email: devops@example.com
dependencies:
- name: postgresql
version: "15.5.2"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
tags:
- database
- name: redis
version: "19.6.1"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: redis.enabled
- name: common
version: "2.x"
repository: "file://../common"
- name: monitoring
version: "0.x"
repository: "https://prometheus-community.github.io/helm-charts"
alias: prometheus
import-values:
- child: defaults
parent: monitoring
Step 3: values.yaml (with documentation)
# @section Global parameters
# @param global.imageRegistry Global Docker image registry
# @param global.imagePullSecrets Global Docker image pull secrets
global:
imageRegistry: ""
imagePullSecrets: []
# @section Application parameters
# @param image.repository Image repository
# @param image.tag Image tag (overrides appVersion)
# @param image.pullPolicy Image pull policy
# @param replicaCount Number of replicas
image:
repository: myorg/my-app
tag: ""
pullPolicy: IfNotPresent
replicaCount: 3
# @section Service parameters
service:
type: ClusterIP
port: 8080
containerPort: 8080
# @section Ingress parameters
ingress:
enabled: false
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
hosts:
- host: app.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: app-tls
hosts:
- app.example.com
# @section Resource requests and limits
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
# @section Autoscaling
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
# @section PodDisruptionBudget
pdb:
enabled: true
minAvailable: 2
# @section Security
podSecurityContext:
fsGroup: 1001
runAsNonRoot: true
runAsUser: 1001
seccompProfile:
type: RuntimeDefault
containerSecurityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
Step 4: Core Templates
# templates/_helpers.tpl
{{- define "my-app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- 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 }}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ include "my-app.name" . }}-{{ .Chart.Version | replace "+" "_" }}
{{ include "my-app.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "my-app.image" -}}
{{- $registry := .Values.global.imageRegistry | default .Values.image.repository -}}
{{- $tag := .Values.image.tag | default (printf "v%s" .Chart.AppVersion) -}}
{{- printf "%s:%s" $registry $tag -}}
{{- end }}
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-app.fullname" . }}
labels:
{{- include "my-app.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
{{- if .Values.autoscaling.enabled }}
replicas: {{ .Values.autoscaling.minReplicas }}
{{- end }}
selector:
matchLabels:
{{- include "my-app.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "my-app.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "my-app.serviceAccountName" . }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: "{{ include "my-app.image" . }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.containerSecurityContext }}
securityContext:
{{- toYaml . | nindent 10 }}
{{- end }}
ports:
- containerPort: {{ .Values.service.containerPort }}
name: http
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 10 }}
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
{{- if .Values.configmap.enabled }}
envFrom:
- configMapRef:
name: {{ include "my-app.fullname" . }}
{{- end }}
volumeMounts:
- name: tmp
mountPath: /tmp
{{- if .Values.extraVolumeMounts }}
{{- toYaml .Values.extraVolumeMounts | nindent 8 }}
{{- end }}
volumes:
- name: tmp
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
Step 5: Helm Hooks
# templates/migration-job.yaml
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": hook-succeeded,before-hook-creation
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ include "my-app.image" . }}"
command: ["python", "manage.py", "migrate"]
# templates/test-connection.yaml (helm test)
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "my-app.fullname" . }}-test"
annotations:
"helm.sh/hook": test
spec:
containers:
- name: curl
image: curlimages/curl:latest
command: ['sh', '-c', 'curl -sf http://{{ include "my-app.fullname" . }}:{{ .Values.service.port }}/healthz']
restartPolicy: Never
Step 6: Helm Unit Tests
# tests/deployment_test.yaml
suite: test deployment
templates:
- deployment.yaml
tests:
- it: should have correct number of replicas
set:
replicaCount: 5
asserts:
- isKind:
of: Deployment
- equal:
path: spec.replicas
value: 5
- it: should set environment variables
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- it: should have security context
asserts:
- isNotNull:
path: spec.template.spec.securityContext
- equal:
path: spec.template.spec.securityContext.runAsNonRoot
value: true
- it: should have resource limits
set:
resources.limits.cpu: 1000m
asserts:
- equal:
path: spec.template.spec.containers[0].resources.limits.cpu
value: 1000m
- it: should not include ingress when disabled
template: ingress.yaml
asserts:
- hasDocuments:
count: 0
- it: should have liveness probe
asserts:
- isNotNull:
path: spec.template.spec.containers[0].livenessProbe
Step 7: Helmfile for Multi-Environment
# helmfile.yaml
repositories:
- name: bitnami
url: https://charts.bitnami.com/bitnami
- name: myrepo
url: oci://ghcr.io/myorg
releases:
- name: my-app
chart: myrepo/my-app
version: 1.2.3
namespace: default
values:
- values/{{ .Environment.Name }}.yaml
secrets:
- secrets/{{ .Environment.Name }}.yaml
values:
- image:
tag: {{ .Environment.Values.imageTag }}
set:
- name: replicaCount
value: {{ .Environment.Values.replicaCount | default 3 }}
conditions:
- my-app.enabled
environments:
dev:
values:
- env/dev.yaml
staging:
values:
- env/staging.yaml
prod:
values:
- env/prod.yaml
- secrets/prod.yaml
Step 8: Helm Secrets (SOPS)
# .sops.yaml
creation_rules:
- path_regex: secrets/.*\.yaml$
age: age1abc123...
encrypted_regex: "^(password|secret|apiKey|token)$"
# secrets/prod.yaml (encrypted with SOPS)
apiVersion: v1
kind: Secret
metadata:
name: my-app-secrets
type: Opaque
stringData:
DATABASE_URL: ENC[AES256_GCM,data:encrypted-base64,iv:...,tag:...]
API_KEY: ENC[AES256_GCM,data:...,iv:...,tag:...]
# Decrypt before helm install (helm plugins)
helm secrets install my-app ./my-chart -f secrets/prod.yaml
# Or with sops directly
sops decrypt secrets/prod.yaml | helm upgrade --install my-app ./my-chart -f -
Step 9: CI/CD Pipeline
# .github/workflows/helm-ci.yaml
name: Helm CI
on:
pull_request:
paths:
- 'charts/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
with:
version: v3.15.0
- name: Lint charts
run: |
helm lint charts/my-app
helm dependency update charts/my-app
helm lint charts/my-app --strict
unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
- name: Run helm-unittest
run: |
helm plugin install https://github.com/helm-unittest/helm-unittest
helm unittest charts/my-app
package:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
needs: [lint, unittest]
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
- name: Package chart
run: |
helm package charts/my-app --destination dist/
- name: Push to OCI registry
run: |
echo ${{ secrets.GITHUB_TOKEN }} | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
helm push dist/my-app-*.tgz oci://ghcr.io/myorg
Rules
- Always use
{{ include }}for shared labels to maintain consistency. - Validate chart with
helm lint --strictbefore committing. - Use
values.schema.jsonfor production charts to catch misconfiguration early. - Pin dependency versions in Chart.yaml — never use
~> 1.xranges in production. - Tests (
helm test) must not have side effects — clean up after themselves. - Use
helm.sh/hook-weightto order hook execution (lower = runs first). - Never template
.Release.Namedirectly — useincludewith truncation. - Use
nindentfor proper YAML indentation within template blocks. - Always run
helm template --debugto verify rendered output matches expectations. - Use
helm-diffplugin in CI to preview changes before apply (helm diff upgrade).
Production Considerations
- Enable
helm.sh/resource-policy: keepfor PVCs and Secrets that must survive uninstall. - Use
helm get manifestandhelm get valuesfor debugging deployed charts. - Package charts with provenance (
helm package --sign) for supply chain security. - OCI registries support Helm charts natively — prefer OCI over Chartmuseum for new setups.
- Use Chart.yaml
kubeVersionto prevent install on incompatible Kubernetes versions. - Helm v3 no longer requires Tiller; use v3 for all new deployments.
- CRDs: install via
crds/directory; Helm will install them before templates. - Use
--atomicfor production upgrades to auto-rollback on failure. - Use
--waitto ensure all resources are ready before marking upgrade as successful. - For large clusters, increase Helm driver performance with
--driver=secret(ConfigMap-based is slow).
Anti-Patterns
- Using
--recreate-pods— forces pod restart; use rolling update instead. - Hardcoding values in templates — use values.yaml for all configurable parameters.
- Single values.yaml for all environments — use helmfile or per-environment files.
- No
helm.sh/hook-delete-policy— failed hooks leave orphaned resources. - Skipping
helm lint— catches YAML errors and template issues early. - No
helm test— no confidence that chart works after deployment. - Using
helm upgradewithout--history-max— accumulates excessive release history. - Templates with inline strings instead of
tplfunction — prevents value-based config. - Chart version not bumped before deployment — can't identify which version is running.
- Large templates with no
_helpers.tpl— violates DRY principle, harder to maintain.
References
- references/helm-advanced.md — Helm Advanced Topics
- references/helm-fundamentals.md — Helm Fundamentals
- references/helmfile.md — Helmfile Multi-Environment Management
- references/helm-secrets.md — SOPS and SealedSecrets for Helm
- references/helm-ci-cd.md — Helm CI/CD Pipeline Patterns
- references/helm-testing.md — Helm Unit and Integration Testing
- references/helm-security.md — Chart Signing and Supply Chain Security
Handoff
devops-kubernetesfor deploying Helm charts to Kubernetes clusters.devops-argo-cdfor GitOps deployment of Helm charts via ArgoCD.devops-gitopsfor GitOps workflow integration.devops-securityfor secrets management and chart signing.devops-cicd-pipelinefor CI/CD integration.