Introduction
Helm is the package manager for Kubernetes, but poorly structured charts cause more pain than they solve. Hardcoded values, untestable templates, and a single massive values.yaml are all too common. After deploying 100+ services with Helm in production, we've distilled the patterns that actually work.
These best practices will save you from 3 AM debugging sessions caused by a stray whitespace in a template. They cover chart structure, dependency management, validation, testing, and production CI/CD — everything you need to ship Helm charts confidently.
Chart Structure
A well-organized chart follows a standard layout that any team member can navigate:
mychart/
Chart.yaml
values.yaml
values.schema.json # Validate user input
templates/
_helpers.tpl # Reusable template functions
deployment.yaml
service.yaml
ingress.yaml
hpa.yaml
NOTES.txt
tests/
deployment_test.yaml
charts/ # Subcharts (managed by helm dependency)
Chart.yaml Essentials
Every Chart.yaml must declare its metadata clearly:
apiVersion: v2
name: api-server
description: REST API service for MyApp
type: application
version: 1.2.0 # Chart version (SemVer)
appVersion: "3.4.1" # Application version
kubeVersion: ">=1.28.0"
maintainers:
- name: Platform Team
email: platform@company.com
Key rules: Always use apiVersion: v2 (Helm 3+), version the chart independently of the app, and specify kubeVersion to prevent installs on incompatible clusters.
Values Validation
Add values.schema.json to catch misconfigurations before they hit the cluster:
{
"$schema": "https://json-schema.org/draft-07/schema",
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1,
"maximum": 100
},
"image": {
"properties": {
"repository": {"type": "string", "pattern": "^[a-z0-9./-]+$"},
"tag": {"type": "string", "minLength": 1}
},
"required": ["repository", "tag"]
}
},
"required": ["replicaCount", "image"]
}
Helm validates this schema before rendering any template. A missing required field fails immediately with a clear error message — far better than a cryptic template panic.
_helpers.tpl Patterns
Standardize labels and selectors across all templates:
{{- define "mychart.labels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "mychart.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Use Named Templates for Repeated Blocks
Resource blocks that repeat across templates (Deployment, StatefulSet, Job) should be extracted:
{{- define "mychart.resources" -}}
resources:
requests:
cpu: {{ .Values.resources.requests.cpu }}
memory: {{ .Values.resources.requests.memory }}
limits:
cpu: {{ .Values.resources.limits.cpu | default .Values.resources.requests.cpu }}
memory: {{ .Values.resources.limits.memory | default .Values.resources.requests.memory }}
{{- end }}
This keeps your templates DRY and makes resource changes propagate everywhere automatically.
Managing Chart Dependencies
Real applications depend on other charts — databases, message queues, monitoring agents. Declare them in Chart.yaml:
dependencies:
- name: postgresql
version: "~12.0"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
alias: db
- name: redis
version: "~18.0"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled
After updating the dependency list, run:
helm dependency update ./mychart
This downloads the charts into charts/ and generates a Chart.lock file. Commit that lock file — it pins versions for reproducible builds.
Conditionals and Tags
Use conditions to enable/disable subcharts per environment:
# values.yaml
postgresql:
enabled: true
redis:
enabled: false # Disabled in dev, use in-memory cache instead
For groups of related subcharts, use tags:
dependencies:
- name: prometheus
tags: ["monitoring"]
- name: grafana
tags: ["monitoring"]
Then toggle the entire group with one flag:
helm install release ./mychart --set tags.monitoring=false
Library Charts
For shared templates used across multiple services, create a library chart:
# Chart.yaml (library chart)
apiVersion: v2
name: my-lib
version: 1.0.0
type: library
Library charts have no templates of their own — they only provide _helpers.tpl that other charts include. This is perfect for standardization across 50+ microservices without copy-pasting.
Testing Charts
Helm Test Hooks
# tests/deployment_test.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "mychart.fullname" . }}-test"
annotations:
"helm.sh/hook": test
spec:
containers:
- name: curl
image: curlimages/curl:latest
command: ['curl', 'http://{{ include "mychart.fullname" . }}:{{ .Values.service.port }}/health']
restartPolicy: Never
Run tests:
helm install myapp ./mychart
helm test myapp
Unit Testing with helm-unittest
helm plugin install https://github.com/helm-unittest/helm-unittest
# tests/deployment_test.yaml
suite: deployment
templates:
- deployment.yaml
tests:
- it: should set correct replica count
set:
replicaCount: 5
asserts:
- equal:
path: spec.replicas
value: 5
- it: should use correct image tag
set:
image.tag: v2.0
asserts:
- matchRegex:
path: spec.template.spec.containers[0].image
pattern: ":v2.0$"
Run all tests:
helm unittest ./mychart
This catches regressions before they reach a cluster. Run it in every Pull Request.
Values Management Patterns
Environment-Specific Overrides
Structure your values files by environment:
values/
values.yaml # defaults
values-production.yaml
values-staging.yaml
values-dev.yaml
Install with targeted overrides:
helm upgrade --install myapp ./mychart -f values/values.yaml -f values/values-production.yaml --set image.tag=v1.5.0
Helm merges these in order — later files override earlier ones.
Global Values
Share values across parent chart and all subcharts using global:
# parent values.yaml
global:
environment: production
dnsDomain: example.com
service:
port: 8080
Any subchart can access {{ .Values.global.environment }}. This is how you propagate cluster-wide settings without repeating them.
CI/CD Integration
Prevent bad charts from ever reaching production:
# .github/workflows/helm-lint.yml
name: Helm CI
on: [pull_request]
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Helm lint
run: helm lint ./charts/*
- name: Helm unit tests
run: |
helm plugin install https://github.com/helm-unittest/helm-unittest
helm unittest ./charts/*
- name: Install in kind cluster
run: |
kind create cluster
helm install test-release ./charts/api-server --wait
helm test test-release
Chart Signing and OCI Distribution
Supply chain security starts with your Helm charts. Signing ensures that the chart hasn't been tampered with between packaging and installation.
Signing Charts with GPG
Generate a signing key and package with a signature:
# Generate GPG key (one-time)
gpg --full-generate-key
# Export the public key
gpg --armor --export devops@company.com > helm-public-key.asc
# Package and sign
helm package ./mychart --sign \
--key 'DevOps Team' \
--keyring ~/.gnupg/secring.gpg
This produces two files: mychart-1.2.0.tgz (the chart) and mychart-1.2.0.tgz.prov (the provenance file). Distribute both.
Verifying on Install
# Import the public key
gpg --import helm-public-key.asc
# Verify during install
helm install myapp ./mychart-1.2.0.tgz --verify
If the signature doesn't match or the key is unknown, Helm refuses to install. In CI/CD, automate verification before any non-development deployment.
OCI Registry Distribution
Store charts in an OCI-compliant registry for access control and immutability:
# Log in to your registry
helm registry login registry.example.com
# Push the chart
helm push mychart-1.2.0.tgz oci://registry.example.com/charts
# Install from OCI
helm install myapp oci://registry.example.com/charts/mychart --version 1.2.0
AWS ECR, GCP Artifact Registry, Azure Container Registry, and Harbor all support OCI Helm charts. This replaces the need for ChartMuseum or static storage buckets.
Advanced CI/CD Patterns
Extend the basic CI pipeline with release automation:
# .github/workflows/helm-release.yml
name: Helm Release
on:
push:
branches: [main]
paths: ['charts/**', 'Chart.yaml']
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Helm lint
run: helm lint ./charts/*
- name: Helm unit tests
run: helm unittest ./charts/*
- name: Package and sign
run: |
helm package ./charts/api-server
helm package ./charts/worker
- name: Push to OCI registry
run: |
helm push api-server-*.tgz oci://${{ secrets.REGISTRY }}/charts
helm push worker-*.tgz oci://${{ secrets.REGISTRY }}/charts
- name: Update ArgoCD image tags
run: |
sed -i "s/tag:.*/tag: ${{ github.sha }}/" deploy/argocd/*.yaml
git commit -am "chore: bump image tag to ${{ github.sha }}"
git push
This pattern — lint, test, sign, push, and update deployment manifests — is the gold standard for Helm-based delivery pipelines.
Helmfile for Multi-Environment Deployments
When you manage dozens of releases across multiple clusters, raw helm commands become unmanageable. Enter Helmfile — a declarative layer that describes your entire Helm state:
# helmfile.yaml
repositories:
- name: bitnami
url: https://charts.bitnami.com/bitnami
releases:
- name: api-server
chart: ./charts/api-server
values:
- values/common.yaml
- values/{{ .Environment.Name }}/api-server.yaml
set:
- name: image.tag
value: "{{ .Values.tag }}"
- name: postgresql
chart: bitnami/postgresql
version: 12.x
namespace: database
environments:
production:
values:
- env/production.yaml
staging:
values:
- env/staging.yaml
Apply the entire stack:
helmfile -e production sync
Helmfile tracks release order, handles dependencies, and runs helm diff before applying. It's the missing orchestration layer for Helm at scale.
ArgoCD Integration with Helm
ArgoCD natively supports Helm charts. Point it to a chart in a Git repository:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: api-server
spec:
source:
repoURL: https://github.com/company/charts.git
path: charts/api-server
targetRevision: main
helm:
valueFiles:
- values/production.yaml
parameters:
- name: image.tag
value: v1.5.0
destination:
server: https://kubernetes.default.svc
namespace: production
ArgoCD renders the Helm template during sync, compares it against live state, and applies only the diff. Enable prune to remove resources removed from the chart:
argocd app set api-server --sync-policy automated --auto-prune
This gives you GitOps workflows with Helm's template power — the best of both worlds.
Conclusion
Good Helm charts are self-documenting, validated, and tested. Use values.schema.json to catch errors early, _helpers.tpl for consistency, and CI/CD to enforce quality. Adopt OCI registries for chart distribution, sign your releases, and unit-test every template. For multi-cluster setups, layer Helmfile or ArgoCD on top.
Start today: add values.schema.json to your most-used chart, write one unit test, and enable --atomic on all production deployments. That's three changes that will immediately reduce deployment failures.