Helm Charts: Templating, Values, and Package Management
Helm Charts guide covering templates, values management, chart repositories, and production deployment workflows.
Helm packages Kubernetes manifests into charts that you can version, share, and deploy with a single command. Charts use Go templates with a cascading values system: defaults in values.yaml get overridden by environment-specific files, then by CLI flags. This guide covers chart structure, template functions, named templates for reusable components, hooks for migrations, and the Helm 3 security improvements over Tiller-based Helm 2. For teams managing multiple environments, Helm reduces deployment toil significantly.
Helm Charts: Templating, Values, and Kubernetes Package Management
Helm is the package manager for Kubernetes. Managing multiple YAML files across different environments gets tedious fast. Helm solves this by letting you package everything into a chart that you can version, share, and deploy with a single command.
This guide covers creating basic charts through advanced templating patterns. If you are new to Kubernetes containers, start with our Docker Fundamentals and Advanced Kubernetes guides first.
Introduction
Helm has a client-server architecture. The Helm client interacts with the server-side component called Tiller in Helm 2, or uses the cluster’s service account in Helm 3.
graph LR
A[Helm Client] -->|Chart| B[Chart Repository]
A -->|Kubeconfig| C[Kubernetes Cluster]
C -->|Release| D[Release History]
C -->|Resources| E[Deployed Resources]
Helm 3 removed Tiller and introduced cluster-scoped service accounts with release-level scope. This was a significant improvement for production use.
Chart Structure
A chart is a collection of files organized in a specific directory structure:
my-chart/
Chart.yaml # Chart metadata
values.yaml # Default configuration values
values.schema.json # Optional JSON schema validation
charts/ # Dependency charts (Helm 3 style)
templates/ # Kubernetes manifest templates
templates/NOTES.txt # Post-install notes
.helmignore # Files to ignore during packaging
Chart.yaml
The Chart.yaml contains the chart’s metadata:
apiVersion: v2
name: my-application
description: A Helm chart for my production application
type: application
version: 1.2.3
appVersion: "2.0.0"
kubeVersion: ">=1.24.0"
keywords:
- web application
- api
home: https://github.com/example/my-app
sources:
- https://github.com/example/my-app
maintainers:
- name: DevOps Team
email: devops@example.com
dependencies:
- name: postgresql
version: "12.x.x"
repository: "https://charts.bitnami.com"
condition: postgresql.enabled
- name: redis
version: "17.x.x"
repository: "https://charts.bitnami.com"
condition: redis.enabled
The apiVersion: v2 format is for Helm 3. Helm 2 used apiVersion: v1.
Values Files
Values files provide configuration that gets merged into templates. Helm uses a cascading values system: default values in values.yaml, overridden by environment-specific files, overridden by command-line flags.
values.yaml
# Default configuration
replicaCount: 3
image:
repository: myregistry/myapp
pullPolicy: IfNotPresent
tag: "1.0.0"
service:
type: ClusterIP
port: 8080
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: api.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: myapp-tls
hosts:
- api.example.com
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 100m
memory: 256Mi
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPUUtilizationPercentage: 70
postgresql:
enabled: true
auth:
database: myapp
username: myapp
primary:
persistence:
size: 10Gi
redis:
enabled: true
auth:
password: ""
Environment-Specific Values
Create environment-specific value files:
# values-staging.yaml
replicaCount: 2
image:
tag: "1.0.0-staging"
resources:
limits:
cpu: 500m
memory: 512Mi
autoscaling:
enabled: false
ingress:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
hosts:
- host: staging-api.example.com
# values-prod.yaml
replicaCount: 5
image:
tag: "1.0.0-production"
resources:
limits:
cpu: 2000m
memory: 2Gi
autoscaling:
minReplicas: 5
maxReplicas: 20
Deploy with specific values:
helm upgrade --install myapp ./charts/myapp \
--values values-prod.yaml \
--namespace production \
--create-namespace
Template Functions and Pipelines
Helm templates use Go template syntax with Sprig functions for manipulation.
Common Functions
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: http
readinessProbe:
httpGet:
path: /ready
port: http
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
String Functions
# Uppercase
appName: { { .Values.name | upper } }
# Lowercase
envVar: { { .Values.env | lower | squote } }
# Truncate
shortName: { { .Values.name | trunc 63 } }
# Replace
fixedName: { { .Values.name | replace "_" "-" } }
# Quote
quoted: { { .Values.name | quote } }
# Default value
tag: { { .Values.image.tag | default .Chart.AppVersion } }
Conditional Logic
# Ternary operator
replicas: {{ .Values.replicaCount | default 1 }}
# If-else blocks
{{- if .Values.ingress.enabled }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" . }}
spec:
{{- with .Values.ingress }}
ingressClassName: {{ .className }}
{{- end }}
{{- end }}
Range Loops
# Loop over list
env:
{{- range .Values.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
# Loop over key-value map
labels:
{{- range $key, $value := .Values.labels }}
{{ $key }}: {{ $value | quote }}
{{- end }}
# Loop with index
{{- range $i, $v := .Values.replicas }}
- name: replica-{{ $i }}
{{- end }}
Named Templates
Named templates (partials) live in templates/_helpers.tpl and you can include them throughout your chart.
Defining Helpers
# templates/_helpers.tpl
{{/*
Expand the name of the chart.
*/}}
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Full name including release and chart.
*/}}
{{- define "myapp.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{ include "myapp.name" . }}: {{ .Release.Name }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ include "myapp.name" . }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Using Helpers in Templates
apiVersion: apps/v1
kind: Deployment
metadata:
name: { { include "myapp.fullname" . } }
labels: { { include "myapp.labels" . | nindent 4 } }
spec:
replicas: { { .Values.replicaCount } }
selector:
matchLabels: { { include "myapp.selectorLabels" . | nindent 6 } }
Chart Dependencies
Charts can depend on other charts. In Helm 3, you define dependencies in Chart.yaml and download them to the charts/ directory.
Dependency Management
# Chart.yaml
dependencies:
- name: postgresql
version: "12.x.x"
repository: "https://charts.bitnami.com"
condition: postgresql.enabled
- name: redis
version: "17.x.x"
repository: "https://charts.bitnami.com"
condition: redis.enabled
Update dependencies:
helm dependency update ./charts/myapp
This creates a Chart.lock file that locks dependency versions.
Sub-chart Values
Parent charts can override sub-chart values:
# values.yaml
postgresql:
primary:
persistence:
size: 50Gi
auth:
database: myapp_prod
username: myapp
redis:
auth:
password: secretpassword
master:
persistence:
enabled: false
Hooks
Hooks run at specific points in the release lifecycle. Use them for database migrations, backup jobs, or waiting for dependencies.
Common Hook Annotations
# hooks/post-install-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-migrations
labels:
{{- include "myapp.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "-1"
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrations
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command:
- /app/migrate.sh
Hook Weights
Each hook gets a numeric weight via the helm.sh/hook-weight annotation. Helm sorts hooks by weight ascending and runs them in that order. The default weight is 0 when you omit the annotation entirely.
Negative weights run before zero, and positive weights run after. This lets you sequence dependent hooks:
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-5"
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "5"
Database migrations are the classic case for negative weights. Set the migration hook at weight -5 and the app startup hook at weight 0. The migration finishes before anything tries to connect.
You can also orchestrate resource creation order this way. A ConfigMap hook at weight -3, a migration job at weight -1, and a deployment at weight 0 guarantees the ConfigMap exists before the migration runs, and the migration completes before the app deploys.
Testing Charts
Helm includes a test framework for validating chart installations.
Test Pod
# templates/tests/pod-test.yaml
apiVersion: v1
kind: Pod
metadata:
name: {{ include "myapp.fullname" . }}-test-connection
labels:
{{- include "myapp.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": test-success
spec:
restartPolicy: Never
containers:
- name: wget
image: busybox:1.36
command:
- wget
args:
- '-O-'
- 'http://{{ include "myapp.fullname" . }}:{{ .Values.service.port }}/health'
Run tests:
helm test myapp --namespace production
Chart Repositories
Chart repositories serve packaged charts via an HTTP server. You can create your own repository for internal charts.
Creating a Repository
# Package your chart
helm package ./charts/myapp
# Create index.yaml
helm repo index ./charts --url https://charts.example.com/
# Serve repository via HTTP
python3 -m http.server 8080 --directory ./charts
Adding and Using Repositories
# Add repository
helm repo add bitnami https://charts.bitnami.com
# Update repositories
helm repo update
# Search for charts
helm search repo bitnami/postgresql
# Install from repository
helm install mydb bitnami/postgresql --values values.yaml
GitHub Pages Repository
Host your chart repository on GitHub Pages:
# In your charts repository
git checkout gh-pages || git checkout -b gh-pages
# Copy your chart package
cp ../my-chart-1.2.3.tgz ./
# Update index
helm repo index . --url https://example.github.io/charts
git add .
git commit -m "Add my-chart v1.2.3"
git push origin gh-pages
Library Charts
Library charts provide reusable templates that other charts can include. They are useful for standardizing common patterns across your organization.
Library Chart Structure
library-chart/
Chart.yaml
templates/
_deployment.yaml
_service.yaml
_configmap.yaml
# Chart.yaml
apiVersion: v2
name: myapp-library
type: library
version: 1.0.0
Using Library Chart
# In your application chart
dependencies:
- name: myapp-library
version: "1.x.x"
repository: "https://charts.example.com"
import-values:
- data
JSON Schema Validation
Values schema files validate values provided to your chart:
// values.schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
"image": {
"type": "object",
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": {
"type": "string",
"enum": ["IfNotPresent", "Always", "Never"]
}
},
"required": ["repository"]
},
"service": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["ClusterIP", "LoadBalancer", "NodePort"]
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
},
"required": ["type", "port"]
}
},
"required": ["image", "service"]
}
If a user provides invalid configuration, Helm rejects it during template rendering:
helm template myapp ./charts/myapp --values invalid-values.yaml
# Error: values don't meet the schema
Best Practices
Production Deployment Checklist
Before pushing a chart to production, run through this checklist. Skipping any of these steps has bitten teams at least once, so treat them as mandatory rather than optional.
Every item here addresses something that has actually gone wrong in a real deployment. The worst ones always look optional until they bite you.
| Category | Action | Why It Matters |
|---|---|---|
| Dependencies | Pin exact versions in Chart.yaml and commit Chart.lock | Loose ranges like 12.x.x can pull breaking changes silently |
| Validation | Add values.schema.json and run helm lint --strict in CI | Catches type errors and missing fields before they hit the cluster |
| Documentation | Comment every value in values.yaml with purpose and constraints | Other teams will thank you when debugging at 2am |
| Stateful ops | Use hooks for migrations, warmups, and seeding; make them idempotent | Non-idempotent hooks accumulate duplicate jobs on every upgrade |
| Testing | Run helm template --debug and helm test in staging before every prod deploy | Catches rendering errors before they block a deployment |
| Secrets | Externalize all credentials via External Secrets Operator or Vault | Committed secrets in git become incidents |
Externalizing secrets is the step that gets skipped most often. It feels like overhead when you are starting out, but retrofitting it into a chart where users have already committed secrets to their values files is painful. Do it from the beginning.
For GitOps teams, add a pre-commit hook that runs helm lint --strict so bad charts never enter git. The pipeline catches the hard errors, but a pre-commit hook stops the trivial ones from ever being committed.
Security Considerations
Security mistakes in Helm charts can expose your whole cluster. These are the ones that show up most often in postmortems:
Most damaging Helm security incidents fall into three buckets: secrets that leaked through version control, overprivileged service accounts, and un-pinned dependencies with known CVEs.
Secrets in values files
Never put credentials, tokens, or certificates into any file that gets committed to git. Once a secret is in git, assume it is compromised. Pull secrets from External Secrets Operator, HashiCorp Vault, or AWS Secrets Manager at runtime. For local development, pass them via --set flags through a gitignored values-local.yaml or environment variables.
Container privileges
Run as non-root whenever possible. Add this to your deployment values:
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
podSecurityContext:
runAsNonRoot: true
fsGroup: 10001
Some containers need root early in their startup (directory permissions, certificate generation) but can drop privileges after. If the image does not support this, look for alternatives or use a startup script that calls su-exec or setpriv before the main process starts.
Scanning dependencies
Every chart dependency is an attack surface. A chart that pulls Bitnami PostgreSQL inherits every CVE in that PostgreSQL version. Run Trivy or Snyk in CI on both the chart and the container images it references:
# Scan chart dependencies
trivy chart ./charts/myapp
# Scan container images
trivy image myregistry/myapp:1.2.3
trivy image myregistry/postgresql:15.2.0
Make the CI job fail on HIGH or CRITICAL vulnerabilities so bad images never reach a cluster.
RBAC for Helm 3
Helm 3 uses the permissions of whichever identity runs helm install or helm upgrade. That identity creates all the Kubernetes resources on your behalf. If you give that identity cluster-admin, a chart bug or a malicious hook can do anything in the cluster. Create a dedicated service account per namespace with only the permissions the chart actually needs:
kubectl create serviceaccount helm-deployer -n production
kubectl create role helm-deployer \
--verb=get,list,watch,create,update,patch \
--resource=deployments,services,configmaps,secrets,pvc -n production
kubectl create rolebinding helm-deployer-binding \
--role=helm-deployer --serviceaccount=production:helm-deployer -n production
Then run Helm with that service account token. If you are running an external secrets operator, grant it read access to the secret store and nothing else.
Release Management
Treat chart releases the same way you treat application releases: versioned, tagged, and automated. Charts that get deployed manually from a laptop work fine until they are the production cluster and the laptop is closed.
The release pipeline
Every chart release should flow through a CI/CD pipeline that runs the same validation steps in the same order every time. A manual helm install from a developer’s laptop is fine for dev, but production deserves a pipeline.
A production-ready pipeline runs these steps in sequence:
- Lint and schema validation:
helm lint --strict ./charts/myapp - Template rendering and diff:
helm template --debug ./charts/myapp - Dependency update and lock:
helm dependency update ./charts/myapp - Dry-run install in a staging namespace:
helm upgrade --install --dry-run --debug - Package:
helm package ./charts/myapp - Push to chart repository or OCI registry
All six steps must pass before the pipeline tags the commit with the chart version and triggers a deployment. This sounds like a lot of overhead, but once it lives in a GitHub Actions workflow or a Jenkins stage, it runs in under two minutes and catches problems that would otherwise require a late-night rollback.
Versioning and tagging
Chart versions follow SemVer (major.minor.patch). The version field in Chart.yaml is the chart version, not the application version. The appVersion field is the application version being packaged. Keep them separate so a patch-level chart bump does not confuse people about which application version is deployed.
Tag your git commits to match chart versions. When myapp-1.2.3 is deployed in production, you want to be able to run git log --oneline --grep="1.2.3" and find the exact commit that produced it. This comes in handy during incidents when you need to understand what changed between the working version and the broken one.
CHANGELOG entries worth writing
A CHANGELOG that says “updated dependencies” is not useful. Write entries that help someone debugging at 11pm:
## 1.3.0
### Added
- `ingress.allowedCidrs` value for restricting access by IP range
### Changed
- `postgresql` sub-chart upgraded from 12.2.0 to 12.8.0 (security patch)
- Default `replicaCount` increased from 2 to 3 for production
### Breaking
- `resources.limits.memory` is now required (previously had no default)
The breaking changes section matters most. It tells anyone upgrading from 1.2.x to 1.3.x exactly what to check before running helm upgrade. Without it, they find out about the memory limit requirement the hard way.
Signing charts
Use helm gpg sign to sign your packaged charts if they are distributed via a shared repository or air-gapped transfer. Recipients verify with helm verify before installing. For internal charts behind a VPN, it is overkill. For public distribution, it is mandatory.
When to Use / When Not to Use
Helm is powerful but not always the right choice. Here is when to use it and when to consider alternatives.
When to Use Helm
Use Helm when:
- You deploy applications to Kubernetes across multiple environments (dev, staging, production)
- You need to manage complex applications with many Kubernetes resources
- You want to version and roll back application configurations
- You are sharing infrastructure or platform components across teams
- You need to package applications for distribution via chart repositories
- Your application has environment-specific configuration that changes between deployments
Use Helm for stateful applications when:
- The application has clear upgrade paths and rollback procedures
- You have tested hooks for database migrations or similar operations
- Storage and secrets are properly externalized from the chart
When Not to Use Helm
Helm solves real problems, but it adds complexity that is not always justified. Here is when Helm is the wrong call.
Simple one-off resources
If you are deploying a single Deployment and Service that will never change, raw YAML with kubectl apply -f is faster and easier to understand. The mental overhead of a chart only pays off when you have enough configuration to templatize.
GitOps workflows with ArgoCD or Flux
GitOps tools want a single declarative state. Helm adds a template rendering step that does not always fit cleanly into the GitOps model. ArgoCD and Flux both have Helm integration, but they work most naturally with rendered YAML or Kustomize overlays. If your team is already all-in on GitOps and Helm is causing friction, Kustomize or raw YAML with ArgoCD App-of-Apps may be a better fit.
Highly dynamic workloads
Helm’s release model assumes you are deploying a known configuration that changes infrequently. If your workload reconfigures itself constantly (dynamic autoscaling based on real-time queue depth, on-the-fly replica count changes from an HPA that responds to external load), Helm’s upgrade cycle is too slow. Kubernetes operators handle workload-managed configurations better.
Strict immutability requirements
Helm upgrades modify the release in place. The release history is a sequence of modifications to the same release object. If you need true immutability where nothing is ever overwritten and only new resources are created, Helm is not designed for that. Some compliance frameworks require immutability, so check whether your requirements conflict with Helm’s model before committing to it.
Air-gapped or security-restricted environments
Helm 3 solved most of Helm 2’s security issues by removing Tiller. That said, some environments restrict what the deployment tool can create in the cluster. Helm 3’s service account model is more granular than Tiller, but it still needs create permissions on the resources your chart deploys. Check whether Helm’s RBAC requirements conflict with your environment’s constraints before adopting it at scale.
Helm vs Alternatives
| Tool | Best For | Limitations |
|---|---|---|
| Helm | Application packaging, multi-environment deployments | Template complexity for very dynamic workloads |
| Kustomize | Overlay-based configuration, Git-native workflows | Less structured than Helm charts |
| ArgoCD | GitOps, declarative continuous delivery | Requires additional setup for templating |
| raw kubectl | One-off deployments, simple resources | No parameterization, no rollback support |
Production Failure Scenarios
Helm failures in production can block deployments or cause unexpected behavior.
| Failure | Impact | Mitigation |
|---|---|---|
| Hook failure | Release marked as failed, subsequent hooks do not run | Design hooks to be idempotent, use hook-delete-policy |
| Template rendering error | Deployment fails silently with wrong configuration | Use --dry-run in CI, validate with JSON schema |
| Dependency unavailable | Chart fails to install or upgrade | Pin exact versions in Chart.lock, maintain mirror |
| Release name collision | Cannot install or upgrade without force | Use namespaces for isolation, unique naming conventions |
| Stuck release (pending-upgrade) | Cannot upgrade or rollback | Use helm rollback or manually remove finalizers |
| Upgrade causes data loss | Stateful workloads may lose data | Always test with dry-run first, backup before upgrade |
| Image pull failures | Pods hang in Pending state | Use private registries with image pull secrets |
| Insufficient cluster resources | Pods cannot be scheduled | Set appropriate resource requests and limits |
Stuck Release Recovery
# Identify stuck releases
helm list --all --failed
# Get release history
helm history myapp --namespace production
# Rollback to last working revision
helm rollback myapp --namespace production
# If rollback fails, manually remove the release
kubectl delete secret -n production -l "owner=helm,name=myapp"
Common Pitfalls / Anti-Patterns
Template Pitfalls
Overusing toYaml
# Anti-pattern: Unbounded toYaml
spec:
{{- toYaml .Values.podSpec | nindent 8 }}
# Better: Explicitly define expected fields
spec:
replicas: {{ .Values.replicaCount }}
selector:
{{- include "myapp.selectorLabels" . | nindent 6 }}
Not handling nil values
# Anti-pattern: Will fail if .Values.ingress is nil
annotations:
{{- .Values.ingress.annotations | toYaml | nindent 8 }}
# Better: Use conditional checks
{{- with .Values.ingress }}
annotations:
{{- .annotations | toYaml | nindent 8 }}
{{- end }}
Forgetting the minus sign for whitespace control
# Anti-pattern: Extra blank line before content
spec:
containers:
- name: app
image: {{ .Values.image }}
# Better: Hyphen trims preceding whitespace
spec:
containers:
- name: app
image: {{ .Values.image }}
Values Structure Pitfalls
Flat values that should be nested
# Anti-pattern: Flat structure makes templating complex
replicaCount: 3
imageRepository: myapp
imageTag: "1.0.0"
servicePort: 8080
# Better: Group related values
replicaCount: 3
image:
repository: myapp
tag: "1.0.0"
service:
type: ClusterIP
port: 8080
Not using JSON schema validation Without schema validation, users can pass any values causing cryptic template errors at render time.
Hook Pitfalls
Non-idempotent hooks
# Anti-pattern: Hook creates duplicate resources each run
metadata:
annotations:
"helm.sh/hook": post-upgrade
"helm.sh/hook-weight": "1"
spec:
containers:
- name: migrate
command: ["/app/migrate.sh"]
# This runs every upgrade, creating duplicates if not cleaned up
Not handling hook failures gracefully
# Anti-pattern: Hook failure blocks entire release
annotations:
"helm.sh/hook": post-upgrade
"helm.sh/hook-failure-policy": fail
# Better: Allow failure without blocking
annotations:
"helm.sh/hook": post-upgrade
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation
Dependency Pitfalls
Not pinning versions
# Anti-pattern: Version range can cause unexpected behavior
dependencies:
- name: postgresql
version: ">=12.0.0"
# Better: Pin exact version for reproducibility
dependencies:
- name: postgresql
version: "12.8.0"
Circular dependencies Avoid charts that depend on each other. This causes installation and upgrade ordering issues.
Key Takeaways & Checklist
Key Takeaways
Helm is worth the learning curve if you are deploying to Kubernetes across multiple environments. The payoff comes when you need to update a database password across 12 services without editing 12 YAML files manually.
What Helm gives you
The core value is templated configuration that moves with your application from dev to staging to production without duplication. The cascading values system (default values.yaml overridden by environment files, overridden by CLI flags) handles most environment-specific differences without requiring separate chart copies.
Named templates in _helpers.tpl are the mechanism for consistency. Define labels, annotations, and naming conventions once and reuse them everywhere. The first time you need to rename a label across 30 resources, you will be glad you did this.
Hooks solve the stateful problem. Database migrations, cache warmups, and configuration seeding are not part of the stateless app deployment, but they have to run at specific points in the release lifecycle. Hooks give you that control. They must be idempotent and re-runnable, which requires upfront thought.
Where people get into trouble
The most common mistakes: not pinning dependency versions (and getting surprise breaking changes), not adding JSON schema validation (and getting cryptic template errors in production), and not externalizing secrets (and leaking credentials in git). All three are preventable.
Library charts are underused. Once you have three or more charts that share the same deployment pattern, extract the common parts into a library chart. Updating one file beats updating fifteen.
The mental model shift
Helm is a packaging system that makes your Kubernetes manifests portable, not just a deployment tool. Think of it as a versioned, distributable unit of deployment rather than a script that applies YAML. When you frame it that way, the chart structure, the versioning, and the release model all make more sense.
Production Readiness Checklist
# Template validation
helm template myapp ./charts/myapp --debug
# Dry-run installation
helm upgrade --install myapp ./charts/myapp \
--dry-run --debug \
--values values-prod.yaml \
--namespace production
# Schema validation
helm lint ./charts/myapp --strict
# Test installation
helm test myapp --namespace production
# Dependency update and lock
helm dependency update ./charts/myapp
helm dependency build ./charts/myapp
# Verify rendered templates
helm get manifest myapp --namespace production
# Check release status
helm status myapp --namespace production
helm history myapp --namespace production
Pre-Production Validation Commands
# Scan for vulnerabilities
trivy image $(helm get values myapp -n production -o jsonpath='{.data.image}' | base64 -d)
# Verify RBAC permissions
kubectl auth can-i get pods --as=system:serviceaccount:production:helm-deployer -n production
# Check for deprecated APIs
helm template myapp ./charts/myapp | kubeval --strict
# Review rendered YAML differences
helm diff upgrade myapp ./charts/myapp --values values-prod.yaml -n production
Observability Checklist
Helm charts need observability at both the release and application level.
Release Monitoring
graph LR
A[Helm Release] --> B[Release Metadata]
A --> C[Revision History]
A --> D[Resource Status]
B --> E[Name Namespace]
B --> F[Chart Version]
C --> G[Upgrade Rollback]
D --> H[Pod Health]
D --> I[PVC Bound]
Track these release metrics:
- Release revision count: too many revisions indicate instability
- Time since last successful deployment
- Number of failed releases across environments
- Hook execution success rates
Application Observability
graph TD
A[Application Metrics] --> B[Pod Resource Usage]
A --> C[Service Endpoints]
A --> D[Ingress Status]
E[Kubernetes Events] --> F[Pod Scheduling]
E --> G[Volume Mounts]
E --> H[Image Pulls]
Metrics to monitor:
- Pod CPU and memory actual usage vs requested
- Service endpoint availability
- Persistent volume claim status and usage
- Ingress controller backend health
- Container image versions deployed
Alert Configuration
Critical alerts:
- Helm release failed during production deployment
- Pods in CrashLoopBackOff after Helm upgrade
- Hook jobs failing repeatedly
- Release revision rapidly increasing (indicates instability)
Warning alerts:
- PVC pending for more than 5 minutes
- Deployment replica count below desired
- Image pull backoff occurring
Security Checklist
Helm charts require security review before production deployment.
Chart Security
-
Scan for vulnerabilities: Use Trivy or Snyk to scan chart dependencies
trivy chart ./charts/myapp -
Verify chart signatures: Use GPG signing for chart verification
helm verify myapp-1.0.0.tgz -
Review values files: Ensure no hardcoded secrets or insecure defaults
-
Validate values schema: Use JSON schema to enforce secure configurations
Image Security
# values.yaml security settings
image:
repository: myregistry.myapp.com/api
pullPolicy: IfNotPresent
securityContext:
runAsNonRoot: true
runAsUser: 10000
podSecurityContext:
runAsNonRoot: true
fsGroup: 10000
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
Image hardening checklist:
- Use specific image tags, never
latest - Scan images for CVEs before deployment
- Use private registry with authentication
- Set
imagePullPolicyappropriately (IfNotPresent or Always)
RBAC for Helm
Helm 3 uses the permissions of the user or service account running it.
# Create dedicated SA for Helm
kubectl create serviceaccount helm-deployer -n production
# Grant only needed permissions
kubectl create role helm-deployer --verb=get,list,watch,create,update,patch,delete \
--resource=deployments,services,configmaps,secrets,pvc -n production
kubectl create rolebinding helm-deployer-binding \
--role=helm-deployer --serviceaccount=production:helm-deployer -n production
Secret Management
Never:
- Commit secrets to values files
- Store secrets in Chart.yaml or templates
- Use ConfigMap for sensitive data
Always:
- Use External Secrets Operator or Vault
- Reference secrets from external secret stores
- Use
--set-filefor certificate contents
Trade-off Analysis
| Aspect | Helm | Kustomize | Carvel (kapp-controller) |
|---|---|---|---|
| Model | Template + values | Overlay + patches | Package + config |
| Learning curve | Moderate (Go templates) | Low (YAML only) | Moderate |
| Reusable packages | Yes (chart repos) | Limited (git-based) | Yes (imgpkg bundles) |
| Debugging | Render and inspect | Build and diff | Build and inspect |
| Ecosystem | Massive (Bitnami) | Growing | Smaller but maturing |
| GitOps friendly | Yes (with Flux/ArgoCD) | Native | Native |
| Secret management | Values encryption | Sealed secrets | Bank-Vaults |
Helmfile vs Helm
Helmfile is a declarative deployment tool that sits above Helm, letting you manage multiple charts and environments in a single configuration file.
When Helmfile Helps
# helmfile.yaml
releases:
- name: myapp
chart: ./charts/myapp
values:
- values-prod.yaml
set:
- name: replicaCount
value: 5
- name: postgresql
chart: bitnami/postgresql
values:
- db-prod.yaml
Key Differences
| Aspect | Helm Alone | Helmfile |
|---|---|---|
| Multi-chart manage | Manual per-chart commands | Single file declares all charts |
| Environment config | Manual —values flags | Environments block with overrides |
| State tracking | helm list per namespace | Declarative state file |
| Templating | Go templates in charts | Helmfile has own templating |
| Sync behavior | Apply individually | Auto detects drift and syncs |
CI/CD Integration
Helmfile excels in GitOps pipelines:
# Preview what would change
helmfile diff
# Apply changes
helmfile apply
# Target specific environment
helmfile --env production apply
Helmfile keeps your Helm deployments declarative and auditable. Use it when managing more than 2-3 charts or multiple environments.
OCI Registry Support
Helm 3 added native OCI support for distributing charts via container registries. This unifies chart distribution with your existing container workflow.
Login to Registry
Authenticating to an OCI registry uses helm registry login, the same command you use for container images. Credentials are stored in ~/.config/helm/auth.yml, separate from the chart.
For interactive logins, provide username and password at the prompt:
helm registry login registry.example.com
For automation pipelines, pass credentials via stdin so passwords never end up in shell history:
echo $OCI_TOKEN | helm registry login -u myuser --password-stdin registry.example.com
Some registries support token-based authentication or keytabs instead of password login. Check your registry documentation for the appropriate authentication method. For CI/CD systems, prefer using service account tokens or workload identity rather than storing static credentials.
Push and Pull Charts
# Package chart first
helm package ./charts/myapp
# Push to OCI registry
helm push myapp-1.2.3.tgz oci://registry.example.com/charts
# Pull from registry
helm pull oci://registry.example.com/charts/myapp --version 1.2.3
Install from OCI
Once your chart is pushed to an OCI registry, installing it works like any Helm install but with an oci:// URL instead of a chart path. You can pin to a specific version or let Helm pull the latest:
# Install specific version
helm upgrade --install myapp oci://registry.example.com/charts/myapp \
--version 1.2.3 \
--namespace production
# Install latest (use with caution in production)
helm upgrade --install myapp oci://registry.example.com/charts/myapp \
--namespace production
The --version flag accepts SemVer constraints like >=1.0.0 if you want to pin a minimum version. Without a version constraint, Helm always resolves to the newest available tag, which can cause surprises if you forget to pin.
Helm pulls the chart layer from the registry, verifies the digest against the stored manifest, and renders it just like a local chart. You can combine OCI installs with --values and --set flags the same way you would with any Helm release.
OCI Workflow Benefits
- Unified auth: Use same credentials as container images
- Existing infra: No separate chart repository server needed
- Mirroring: Charts travel with container images in air-gapped setups
- Versioning: OCI registries handle deduplication and layering
Limitations: OCI registries do not serve index.yaml, so helm search does not work directly. Use helm fetch with exact versions.
Rollback Strategies
Helm tracks every release revision. When deployments go sideways, you have multiple rollback strategies.
Basic Rollback
Helm keeps a full revision history for every release, so you can always undo an upgrade. The simplest case is rolling back to the previous revision, which requires no additional arguments:
# Rollback to previous revision
helm rollback myapp --namespace production
# Rollback to specific revision
helm rollback myapp 3 --namespace production
When you specify a revision number, Helm rolls back to that exact revision regardless of how far back it is. Revision numbers start at 1 for the initial install and increment with each upgrade. Running helm history myapp --namespace production shows you the full revision timeline with timestamps and status, so you can pick the right revision to roll back to.
Rollback is treated as an upgrade in Helm’s model, so it creates a new revision (revision 5 after rolling back from revision 4 to revision 3). This keeps the full audit trail intact so you never lose history.
Rollback with Dry-Run
Always verify before rolling back in production:
# See what the rollback would do
helm rollback myapp --dry-run --debug --namespace production
# Check release history first
helm history myapp --namespace production
Output:
REVISION UPDATED STATUS CHART DESCRIPTION
1 2026-03-15 10:30:00 superseded myapp-1.0.0 Install complete
2 2026-03-15 11:45:00 superseded myapp-1.1.0 Upgrade complete
3 2026-03-15 14:20:00 failed myapp-1.2.0 Upgrade failed
4 2026-03-15 14:25:00 deployed myapp-1.1.0 Rollback complete
Selective Rollback to Known Good State
For critical applications where you know a specific revision is stable, you can target it directly rather than relying on the immediately previous revision:
# Identify stable revision
helm history myapp --namespace production | grep superseded
# Rollback two revisions (from bad upgrade to last good)
helm rollback myapp 2 --namespace production
Looking at the revision history, entries marked superseded are safe rollback targets because they represent a state that previously deployed successfully. Entries marked failed indicate an upgrade that did not complete, and deployed is the current active state. For stateful applications, document your known-good revisions for each major version in a runbook so on-call engineers are not guessing during an incident.
Before rolling back in production, always run helm rollback --dry-run --debug to see exactly what will change. This is especially important for stateful workloads where the rollback might affect persistent storage or database schemas. If the dry-run output looks correct, proceed with the actual rollback but keep the output handy for post-incident review.
Automated Rollback in CI/CD
# In your deployment pipeline
helm upgrade --install myapp ./charts/myapp \
--values values-prod.yaml \
--namespace production \
--atomic \
--timeout 5m
# --atomic automatically rolls back on failure
Preventing Bad Rollbacks
# Lock release so it cannot be accidentally rolled back
helm upgrade --install myapp ./charts/myapp \
--set locked=true
# Use fail-fast hooks to catch issues early
helm upgrade myapp ./charts/myapp --timeout 2m
Interview Questions
Expected answer points:
- Helm is the package manager for Kubernetes, bundling related manifests into a reusable chart
- It provides templating so one chart works across dev, staging, and production with different values
- Release management includes versioning, rollback, and upgrade tracking that kubectl lacks
- Charts can be shared via repositories, enabling reuse of community charts like Bitnami's
Expected answer points:
- Helm 2 used Tiller, a server-side component that ran inside the cluster and managed releases
- Helm 3 removed Tiller entirely, using cluster service accounts instead for security
- Release metadata moved from cluster storage to secrets, reducing RBAC complexity
- Three-way (or four-way) merge diffs in Helm 3 prevent accidental rollbacks on drift
Expected answer points:
- Default values live in values.yaml inside the chart
- Environment-specific files (values-staging.yaml, values-prod.yaml) override defaults
- Command-line --set and --set-file flags override everything
- Order matters: defaults < environment file < CLI flags
Expected answer points:
- Named templates (partials) live in templates/_helpers.tpl and are reusable across templates
- Define once, include everywhere with {{ include "myapp.labels" . | nindent 4 }}
- Common uses: chart name, fullname, labels, selectorLabels, common annotations
- Reduce duplication and enforce consistency across Kubernetes resources
Expected answer points:
- Hooks run Jobs or Pods at specific lifecycle points: pre-install, post-install, pre-upgrade, post-upgrade, pre-delete, post-delete
- Hook weight controls execution order (negative runs first)
- Design hooks to be idempotent: use hook-delete-policy to re-run cleanly
- Database migrations are the classic use case; they must tolerate being re-run
- hook-delete-policy: hook-succeeded,before-hook-creation prevents duplicates
Expected answer points:
- Never commit secrets to values.yaml or Chart.yaml
- Use External Secrets Operator to sync secrets from Vault, AWS Secrets Manager, or GCP Secret Manager
- Use --set-file to load certificate or key file contents at deploy time
- HashiCorp Vault CSI provider can inject secrets as mounted files
- For testing, use test values that are clearly marked as non-production
Expected answer points:
- Application charts produce actual Kubernetes resources when installed
- Library charts set type: library and define reusable template partials (_deployment.yaml, _service.yaml)
- Other charts depend on library charts and import their templates
- Library charts are useful for standardizing organization-wide patterns
Expected answer points:
- values.schema.json enforces type, constraints, and required fields on values provided to the chart
- Helm validates during helm template and helm install/upgrade with --strict flag
- Rejects invalid configuration before any resources are rendered
- Example: replicaCount must be integer 1-10, image.repository is required, pullPolicy enum
Expected answer points:
- Helmfile excels when you manage multiple charts across multiple environments in one place
- Useful when your deployment involves 3+ charts that need to be deployed together
- Environment blocks with overrides keep environment-specific config clean and auditable
- helmfile diff shows exactly what would change before applying, useful in CI/CD
- Not needed for simple single-chart deployments where Helm alone suffices
Expected answer points:
- Always check helm history before rolling back to see all revisions
- Use helm rollback with --dry-run --debug to preview the rollback first
- Use the --atomic flag during upgrade to auto-rollback on failure
- Set a timeout to prevent indefinite hangs during broken deployments
- Store rollback playbooks in runbooks so on-call engineers do not have to improvise
Expected answer points:
- Use helm template to render charts locally without cluster access
- Use --debug with helm install/upgrade to see rendered manifests
- Use helm get manifest to inspect what was actually deployed
- Use --dry-run --debug to validate without making changes
- Use printf debugging in templates with {{ .Values | toJson }}
- Run helm lint to catch syntax errors and schema violations
Expected answer points:
- Application charts set type: application and produce actual Kubernetes resources when installed
- Library charts set type: library and define reusable template partials (_deployment.yaml, _service.yaml)
- Other charts depend on library charts and import their templates via import-values
- Library charts are useful for standardizing organization-wide patterns across teams
- Library charts cannot install resources directly, only provide templates
Expected answer points:
- Dependencies are defined in Chart.yaml under dependencies[] with name, version, and repository
- Run helm dependency update to download dependencies to the charts/ directory
- Chart.lock is auto-generated and locks exact versions from the dependency resolution
- Commit Chart.lock to ensure reproducible installs across machines
- Use helm dependency build to install from Chart.lock without re-resolving
- Conditions and tags in Chart.yaml control when sub-charts are enabled
Expected answer points:
- Lifecycle: install → upgrade → uninstall, each with pre/post phases
- Hook types: pre-install, post-install, pre-upgrade, post-upgrade, pre-delete, post-delete, test
- Hook weight determines execution order (negative weights run first)
- Use hook-delete-policy to control when resources are cleaned up (hook-succeeded, before-hook-creation, hook-failed)
- Hooks are Jobs that run to completion; release waits for hook to finish
- Failed hooks mark the release as failed and block subsequent hooks
Expected answer points:
- Helm 3 uses RBAC service accounts, so grant only necessary permissions
- Never embed secrets in values files; use External Secrets Operator or Vault
- Sign charts with GPG and verify with helm verify before installation
- Scan charts and container images for vulnerabilities with Trivy or Snyk
- Use --set-file for certificates instead of hardcoding values
- Lock chart versions in Chart.lock to prevent unexpected updates
Expected answer points:
- values.schema.json defines type constraints, required fields, and allowed values
- Helm validates during helm template and helm install/upgrade --strict
- Rejects invalid configuration before any resources are rendered to the cluster
- Prevents runtime errors from missing required fields or wrong types
- Example: replicaCount must be integer 1-10, image.repository is required, pullPolicy enum
Expected answer points:
- Use OCI when you want unified authentication with container images (same registry credentials)
- Useful for air-gapped environments where charts travel with container images
- OCI registries handle deduplication and layering efficiently
- Choose traditional repos when you need helm search repo functionality
- OCI works well with GitOps workflows where charts are versioned alongside images
- Limitation: no index.yaml means helm search does not work with OCI registries
Expected answer points:
- Helmfile is a declarative tool that sits above Helm, managing multiple charts in one file
- Prefer Helmfile when managing 3+ charts or multiple environments (dev, staging, production)
- Environment blocks with overrides keep environment-specific config auditable
- helmfile diff shows exactly what would change before applying, useful in CI/CD
- Single source of truth for all chart deployments in a project
- Not needed for simple single-chart deployments where Helm alone suffices
Expected answer points:
- Each microservice gets its own chart in a charts/ directory at repo root
- Use Helmfile at the repo root to declare all chart releases and their dependencies
- Extract shared templates into library charts that all microservices import
- Use values-{env}.yaml files at the repo level, not inside individual charts
- Group related services (e.g., backend-api, frontend-web) under a single release if they deploy together
- Use hook-weight for migration jobs that must run before application pods start
Expected answer points:
- Helm 3 introduced three-way merge to prevent accidental rollback on configuration drift
- Three-way merge considers: last release manifest, current cluster state, new values
- Prevents overwriting changes made directly in the cluster that are not in the previous release
- Four-way diff (upgrade) compares: last release, current cluster, new template, new values
- Result: only changes from new values are applied, cluster changes outside Helm are preserved
- This makes Helm 3 safer for production use where cluster state may diverge from git
Further Reading
- Helm official documentation - The authoritative source for Helm usage and chart development
- Artifact Hub - Browse thousands of community Helm charts for popular software
- Bitnami Labs - Production-ready charts for databases, caches, and middleware
- Helmfile documentation - Declarative spec for deploying Helm charts
- External Secrets Operator - Integrate Kubernetes secrets with external secret stores
- Helm unittest plugin - Write unit tests for your chart templates
- ct (Chart Testing) CI tool - Lint and test charts in CI pipelines
Conclusion
Helm makes Kubernetes deployments actually manageable. Without it, you are juggling YAML files across environments, hoping nothing drifts out of sync. The chart structure, Go templating, and dependency system feel clunky at first, but once they click, you will wonder how you lived without it.
Start by converting your existing Kubernetes manifests to a chart. Add templating for environment-specific values. Then extract reusable components into library charts as patterns emerge. You will iterate on your chart structure as you go and that is fine.
For continued learning, explore the Advanced Kubernetes guide for operators and controllers that work alongside Helm. The Prometheus & Grafana guides cover observability for your Helm-deployed applications.
Category
Related Posts
Developing Helm Charts: Templates, Values, and Testing
Create production-ready Helm charts with Go templates, custom value schemas, and testing using Helm unittest and ct.
Helm Versioning and Rollback: Managing Application Releases
Master Helm release management—revision history, automated rollbacks, rollback strategies, and handling failed releases gracefully.
Advanced Kubernetes: Controllers, Operators, RBAC, Production Patterns
Explore Kubernetes custom controllers, operators, RBAC, network policies, storage classes, and advanced patterns for production cluster management.