Helm Chart Best Practices for Production Kubernetes
Master Helm chart best practices for production—chart structure, values management, hooks, testing with helm test, and the most common patterns and anti-patterns.
Helm is the de-facto package manager for Kubernetes, but “it works” and “it’s production-ready” are two very different standards. Charts that work on a developer’s laptop frequently fail under the pressures of multi-environment deployments, version upgrades, and team collaboration.
This guide covers the practices that separate Helm charts worth running in production from ones that cause 3 AM incidents.
Chart Structure Fundamentals
A well-structured Helm chart is predictable and navigable. The standard layout:
my-chart/
├── Chart.yaml # Chart metadata and dependencies
├── values.yaml # Default values (developer-facing documentation)
├── values.schema.json # JSON schema for values validation
├── charts/ # Dependency charts (vendored)
├── templates/
│ ├── _helpers.tpl # Named templates and helpers
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ ├── serviceaccount.yaml
│ ├── hpa.yaml
│ ├── networkpolicy.yaml
│ ├── NOTES.txt # Post-install instructions
│ └── tests/
│ └── test-connection.yaml
└── .helmignore
Chart.yaml — Do This Right
apiVersion: v2
name: my-api
description: "REST API service for the payments domain"
type: application
version: 1.4.2 # Chart version — bump on any template change
appVersion: "2.1.0" # Application version — tracks image tag
keywords:
- api
- payments
maintainers:
- name: payments-team
email: [email protected]
dependencies:
- name: postgresql
version: "~14.0"
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
The distinction between version and appVersion matters. Chart version tracks template changes. appVersion tracks what software the chart deploys. They should move independently.
Values Management
Your values.yaml is the primary interface between your chart and its users. Treat it as public API.
Structure Values for Clarity
# values.yaml — organized, documented, with sensible defaults
# -- Number of replicas. Ignored when autoscaling.enabled=true
replicaCount: 2
image:
# -- Container image repository
repository: registry.company.com/payments/my-api
# -- Image pull policy
pullPolicy: IfNotPresent
# -- Overrides the image tag. Defaults to .Chart.AppVersion
tag: ""
# -- Annotations to add to the pod
podAnnotations: {}
# -- Security context for the pod
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
# -- Security context for containers
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
service:
type: ClusterIP
port: 80
targetPort: 8080
ingress:
enabled: false
className: "nginx"
annotations: {}
hosts:
- host: my-api.example.com
paths:
- path: /
pathType: Prefix
tls: []
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 20
targetCPUUtilizationPercentage: 70
# -- Environment-specific configuration
config:
logLevel: "info"
databaseUrl: "" # Required — must be overridden
# -- External secret references
externalSecrets:
enabled: true
secretStoreRef:
name: aws-secretsmanager
kind: ClusterSecretStore
Add a JSON Schema for Validation
Values schema validation catches misconfigurations before they reach the cluster:
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["image"],
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1,
"maximum": 100
},
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": {
"type": "string",
"pattern": "^registry\\.company\\.com/"
},
"pullPolicy": {
"type": "string",
"enum": ["Always", "IfNotPresent", "Never"]
}
}
},
"resources": {
"type": "object",
"required": ["limits", "requests"]
}
}
}
When a user runs helm install with invalid values, they get a clear error instead of a broken deployment.
Template Best Practices
Use _helpers.tpl Consistently
Define your naming conventions once and reuse them everywhere:
{{/*
Expand the name of the chart.
*/}}
{{- define "my-api.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "my-api.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{/*
Common labels for all resources.
*/}}
{{- define "my-api.labels" -}}
helm.sh/chart: {{ include "my-api.chart" . }}
{{ include "my-api.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels (stable — used in matchLabels).
*/}}
{{- define "my-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Never Hardcode Namespace
Always use {{ .Release.Namespace }} in resource metadata. Hardcoded namespaces break multi-environment deployments.
Handle Optional Sections Cleanly
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "my-api.fullname" . }}
labels:
{{- include "my-api.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
...
{{- end }}
Hooks for Lifecycle Management
Helm hooks let you run jobs at specific points in the release lifecycle:
# Pre-upgrade database migration job
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "my-api.fullname" . }}-migrate
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5" # Lower weight runs first
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
serviceAccountName: {{ include "my-api.serviceAccountName" . }}
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command: ["./migrate", "--up"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "my-api.fullname" . }}-secrets
key: databaseUrl
Available hooks:
pre-install,post-installpre-upgrade,post-upgradepre-rollback,post-rollbackpre-delete,post-deletetest(for helm test)
Testing with helm test
helm test runs test pods against a deployed release. Every chart should have tests:
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "my-api.fullname" . }}-test-connection"
labels:
{{- include "my-api.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
restartPolicy: Never
containers:
- name: test-health
image: curlimages/curl:latest
command:
- /bin/sh
- -c
- |
curl -sf http://{{ include "my-api.fullname" . }}:{{ .Values.service.port }}/health || exit 1
echo "Health check passed"
- name: test-readiness
image: curlimages/curl:latest
command:
- /bin/sh
- -c
- |
curl -sf http://{{ include "my-api.fullname" . }}:{{ .Values.service.port }}/ready || exit 1
echo "Readiness check passed"
Run tests after deployment:
helm install my-api ./my-chart -f values-staging.yaml
helm test my-api --logs
Multi-Environment Values Strategy
Structure your values files to minimize duplication:
values/
├── values.yaml # Base defaults (committed)
├── values-staging.yaml # Staging overrides (committed)
├── values-production.yaml # Production overrides (committed)
└── values-secrets.yaml # Secrets (never committed, from CI/CD)
Override files should only contain differences:
# values-production.yaml
replicaCount: 5
autoscaling:
enabled: true
minReplicas: 5
maxReplicas: 50
resources:
limits:
cpu: "2"
memory: 2Gi
requests:
cpu: 500m
memory: 512Mi
ingress:
enabled: true
hosts:
- host: api.company.com
Deploy with layered values:
helm upgrade --install my-api ./my-chart \
-f values/values.yaml \
-f values/values-production.yaml \
-f values/values-secrets.yaml \
--set image.tag=$CI_COMMIT_SHA \
--namespace production \
--atomic \ # Roll back automatically on failure
--timeout 5m \
--wait # Wait for all pods to be ready
Common Anti-Patterns to Avoid
1. Hardcoded image tags
Never use latest or hardcode a version in values.yaml. Always set image.tag at deploy time from CI/CD.
2. Missing resource limits
Every container should have resources.requests and resources.limits. Without them, a noisy pod can evict your entire namespace.
3. No liveness or readiness probes
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
4. Single replica in production
Default replicaCount: 1 is fine for development. Production values should always override to 2+ with a PodDisruptionBudget:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "my-api.fullname" . }}-pdb
spec:
minAvailable: 1
selector:
matchLabels:
{{- include "my-api.selectorLabels" . | nindent 6 }}
5. No chart linting in CI
# Lint before every merge
helm lint ./my-chart -f values/values.yaml
# Dry-run to catch template errors
helm upgrade --install my-api ./my-chart \
-f values/values.yaml \
--dry-run \
--debug
Conclusion
Helm charts are infrastructure code. They deserve the same review standards, testing discipline, and documentation rigor as your application code. The practices in this guide—schema validation, layered values, helm tests, hooks for migrations, and PodDisruptionBudgets—are what separate a “works on my machine” chart from one your team can rely on at 2 AM during a production incident.
Start by auditing your existing charts against this checklist. Most improvement comes from small, consistent changes: adding probes, adding resource limits, splitting values by environment. Each one reduces risk and increases confidence in your deployments.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links — we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.