- Published on
Helm Charts in Production — Templating, Testing, and Chart Promotion Strategies
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Helm is the package manager for Kubernetes. A well-designed chart is reusable, testable, and deployable across dev, staging, and production with minimal configuration drift. A poorly designed chart becomes a maintenance nightmare—filled with conditional logic, hard-coded values, and charts that work in dev but fail mysteriously in prod. This post covers chart structure, templating best practices, testing strategies, and chart promotion pipelines for production deployments.
- Chart Structure and Best Practices
- values.yaml Design with Sensible Defaults
- Named Templates and Helpers
- Pre/Post Hooks and Lifecycle Management
- Testing with helm-unittest
- Chart Promotion: Dev → Staging → Prod
- Helmfile for Multi-Chart Orchestration
- Chart Repository with OCI Registry
- Checklist
- Conclusion
Chart Structure and Best Practices
A production-grade Helm chart follows a consistent structure that scales with complexity.
my-app-chart/
├── Chart.yaml
├── values.yaml
├── values-prod.yaml
├── values-staging.yaml
├── templates/
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ ├── secret.yaml
│ ├── serviceaccount.yaml
│ ├── hpa.yaml
│ ├── pdb.yaml
│ ├── networkpolicy.yaml
│ └── tests/
│ ├── test-deployment.yaml
│ └── test-connection.yaml
├── charts/
│ └── postgresql/ (if bundling dependencies)
├── hooks/
│ ├── pre-install.yaml
│ ├── post-install.yaml
│ ├── pre-upgrade.yaml
│ └── post-upgrade.yaml
└── README.md
Chart.yaml defines metadata:
apiVersion: v2
name: my-app
description: Production application
type: application
version: 1.5.2
appVersion: "2.3.1"
maintainers:
- name: Platform Team
email: platform@example.com
dependencies:
- name: postgresql
version: "14.x"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: postgresql.enabled
keywords:
- api
- production
- microservices
values.yaml Design with Sensible Defaults
The base values.yaml must be production-safe. New teams deploying the chart should get secure, performant defaults without additional configuration.
replicaCount: 3
image:
repository: my-app
pullPolicy: IfNotPresent
tag: "2.3.1"
imagePullSecrets:
- name: registry-credentials
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations:
iam.gke.io/gcp-service-account: "my-app@my-project.iam.gserviceaccount.com"
name: ""
podAnnotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
limits:
cpu: 1000m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPUUtilizationPercentage: 75
targetMemoryUtilizationPercentage: 80
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- my-app
topologyKey: kubernetes.io/hostname
persistence:
enabled: false
size: 10Gi
storageClassName: "fast-ssd"
serviceMonitor:
enabled: true
interval: 30s
scrapeTimeout: 10s
postgresql:
enabled: false
auth:
username: myapp
password: changeme
database: myappdb
Named Templates and Helpers
Define reusable template fragments in _helpers.tpl:
{{/*
Expand the name of the chart.
*/}}
{{- define "my-app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- 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 }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "my-app.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ include "my-app.chart" . }}
{{ include "my-app.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Use helpers in templates:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-app.fullname" . }}
labels:
{{- include "my-app.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- 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:
serviceAccountName: {{ include "my-app.fullname" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
Pre/Post Hooks and Lifecycle Management
Helm hooks execute custom logic during installation or upgrades. Common use cases: database migrations, cache invalidation, data backups.
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "my-app.fullname" . }}-db-migrate
labels:
{{- include "my-app.labels" . | nindent 4 }}
annotations:
helm.sh/hook: pre-upgrade
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
template:
spec:
serviceAccountName: {{ include "my-app.fullname" . }}
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command:
- /app/migrate.sh
env:
- name: DB_HOST
value: {{ .Values.database.host }}
- name: DB_PORT
value: {{ .Values.database.port | quote }}
- name: DB_USER
valueFrom:
secretKeyRef:
name: {{ include "my-app.fullname" . }}-db
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "my-app.fullname" . }}-db
key: password
Hook weight controls execution order. Negative weights run before positive:
annotations:
helm.sh/hook: pre-upgrade
helm.sh/hook-weight: "-5" # Runs first
---
annotations:
helm.sh/hook: pre-upgrade
helm.sh/hook-weight: "0" # Runs second
---
annotations:
helm.sh/hook: pre-upgrade
helm.sh/hook-weight: "5" # Runs last
Testing with helm-unittest
helm-unittest validates chart rendering and values without deploying:
helm plugin install https://github.com/helm-unittest/helm-unittest.git
Create tests/deployment_test.yaml:
suite: test deployment
templates:
- deployment.yaml
tests:
- it: should render deployment with default values
asserts:
- isKind:
of: Deployment
- equal:
path: metadata.name
value: RELEASE-NAME-my-app
- equal:
path: spec.replicas
value: 3
- it: should scale replicas when autoscaling disabled
set:
autoscaling.enabled: false
replicaCount: 5
asserts:
- equal:
path: spec.replicas
value: 5
- it: should set security context
asserts:
- equal:
path: spec.template.spec.securityContext.runAsNonRoot
value: true
- equal:
path: spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation
value: false
- it: should render HPA when autoscaling enabled
set:
autoscaling.enabled: true
templates:
- hpa.yaml
asserts:
- isKind:
of: HorizontalPodAutoscaler
- equal:
path: spec.minReplicas
value: 3
- equal:
path: spec.maxReplicas
value: 10
Run tests:
helm unittest ./my-app-chart
Chart Promotion: Dev → Staging → Prod
Use separate values files or Helmfile to manage environment-specific configurations:
helmfile.yaml:
helmDefaults:
atomic: true
wait: true
timeout: 600
recreatePods: false
releases:
- name: my-app
namespace: production
chart: ./my-app-chart
version: 1.5.2
values:
- ./values.yaml
- ./values-prod.yaml
hooks:
- events: ["presync"]
showlogs: true
command: "sh"
args: ["pre-upgrade-checks.sh"]
- events: ["postsync"]
showlogs: true
command: "sh"
args: ["post-upgrade-validation.sh"]
secrets:
- values-prod-secrets.yaml
values-prod.yaml:
replicaCount: 5
image:
tag: "2.3.1" # Pin specific version in prod
resources:
limits:
cpu: 1500m
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 5
maxReplicas: 50
targetCPUUtilizationPercentage: 70
persistence:
enabled: true
size: 100Gi
postgresql:
enabled: true
auth:
username: produser
primary:
persistence:
enabled: true
size: 50Gi
Helmfile for Multi-Chart Orchestration
Helmfile manages multiple charts and their dependencies:
helmDefaults:
atomic: true
wait: true
waitForJobs: true
timeout: 600
repositories:
- name: bitnami
url: https://charts.bitnami.com/bitnami
- name: prometheus-community
url: https://prometheus-community.github.io/helm-charts
- name: myrepo
url: oci://registry.example.com/helm
releases:
# Infrastructure
- name: postgres
namespace: databases
chart: bitnami/postgresql
version: 14.0.0
values:
- ./values-postgres.yaml
# Monitoring
- name: prometheus
namespace: monitoring
chart: prometheus-community/prometheus
version: 25.0.0
values:
- ./values-prometheus.yaml
# Application
- name: my-app
namespace: production
chart: myrepo/my-app
version: 1.5.2
values:
- ./values.yaml
- ./values-prod.yaml
depends:
- databases/postgres
- monitoring/prometheus
- name: my-app-worker
namespace: production
chart: myrepo/my-app-worker
version: 1.2.1
values:
- ./values-worker.yaml
depends:
- production/my-app
Deploy with:
helmfile -f helmfile.yaml apply
Chart Repository with OCI Registry
Store charts in OCI registries (Docker registries) for version control and immutability:
# Package chart
helm package ./my-app-chart
# Push to OCI registry
helm push my-app-chart-1.5.2.tgz oci://registry.example.com/helm
# Pull and deploy
helm upgrade --install my-app oci://registry.example.com/helm/my-app --version 1.5.2
github/workflows/chart-release.yaml:
name: Release Helm Chart
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v3
- run: helm package ./my-app-chart
- run: |
helm registry login -u ${{ secrets.REGISTRY_USER }} \
-p ${{ secrets.REGISTRY_PASSWORD }} \
registry.example.com
helm push my-app-chart-*.tgz oci://registry.example.com/helm
Checklist
- Chart follows standard directory structure
- values.yaml has secure, production-ready defaults
- All templates use named helpers from _helpers.tpl
- Security context enforces non-root, read-only filesystem
- Probes configured with reasonable timeouts and thresholds
- Chart passes helm lint without warnings
- Unit tests cover all major branches and edge cases
- Pre/post hooks for migrations and health checks
- Environment-specific values files (dev, staging, prod)
- Charts versioned and stored in OCI registry
- Documentation (README.md) explains customization points
- Chart promotion tested across all environments before prod
Conclusion
A production Helm chart is infrastructure as code—version controlled, tested, and promoted systematically. Invest upfront in sensible defaults, comprehensive tests, and environment promotion pipelines. This discipline prevents configuration drift, reduces deployment failures, and enables teams to deploy with confidence. Helmfile layers on orchestration for multi-chart stacks, making it possible to manage entire platforms reproducibly.