Helm Best Practices for Production Deployments
Building production-ready Helm charts requires more than basic templating knowledge. Well-designed charts balance flexibility with simplicity, enforce security standards, handle upgrades gracefully, and provide clear operational guidance. Following established best practices ensures charts remain maintainable, reliable, and secure as applications evolve and scale across diverse environments.
Chart Structure and Organization
Organize charts for clarity and maintainability:
graph TB
A[Production Chart
Structure] --> B[Core Templates]
A --> C[Helper Functions]
A --> D[Documentation]
A --> E[Testing]
B --> B1[Essential resources]
B --> B2[Optional features]
B --> B3[Clear conditionals]
C --> C1[Reusable labels]
C --> C2[Naming conventions]
C --> C3[Complex logic]
D --> D1[README.md]
D --> D2[NOTES.txt]
D --> D3[values.yaml comments]
E --> E1[Linting]
E --> E2[Template tests]
E --> E3[Integration tests]
Recommended Structure:
my-chart/
├── Chart.yaml # Chart metadata
├── README.md # Usage documentation
├── values.yaml # Well-documented defaults
├── values.schema.json # JSON schema validation
├── .helmignore # Files to exclude
├── charts/ # Dependencies
├── templates/
│ ├── _helpers.tpl # Helper functions
│ ├── deployment.yaml # Core resource
│ ├── service.yaml
│ ├── ingress.yaml # Optional features
│ ├── hpa.yaml
│ ├── pdb.yaml # Resilience
│ ├── serviceaccount.yaml # Security
│ ├── configmap.yaml # Configuration
│ ├── secret.yaml # Sensitive data
│ ├── NOTES.txt # Post-install guidance
│ └── tests/
│ └── test-connection.yaml
├── ci/ # CI value files
│ ├── values-dev.yaml
│ ├── values-staging.yaml
│ └── values-production.yaml
└── docs/ # Additional documentation
├── ARCHITECTURE.md
└── UPGRADE.md
Naming Conventions
Consistent naming prevents conflicts and improves clarity:
Resource Names:
# templates/_helpers.tpl
{{- define "myapp.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 }}
# Usage: Ensures unique names per release
metadata:
name: {{ include "myapp.fullname" . }}
Component Naming:
# For multi-component applications
{{- define "myapp.frontend.fullname" -}}
{{ include "myapp.fullname" . }}-frontend
{{- end }}
{{- define "myapp.backend.fullname" -}}
{{ include "myapp.fullname" . }}-backend
{{- end }}
{{- define "myapp.worker.fullname" -}}
{{ include "myapp.fullname" . }}-worker
{{- end }}
Label Standards:
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
{{ include "myapp.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- if .Values.commonLabels }}
{{ toYaml .Values.commonLabels }}
{{- end }}
{{- end }}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Values Organization
Structure values for intuitive configuration:
values.yaml with Documentation:
# Global settings shared across sub-charts
global:
# Image registry for all images
imageRegistry: ""
# Image pull secrets
imagePullSecrets: []
# Number of replicas (ignored if autoscaling enabled)
replicaCount: 2
## Container image configuration
image:
# Image repository
repository: mycompany/myapp
# Image pull policy (Always, IfNotPresent, Never)
pullPolicy: IfNotPresent
# Overrides the image tag (default: Chart.appVersion)
tag: ""
## Service account configuration
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account (generated if not set)
name: ""
## Pod security configuration
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
## Container security configuration
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
capabilities:
drop:
- ALL
## Service configuration
service:
# Service type (ClusterIP, NodePort, LoadBalancer)
type: ClusterIP
# Service port
port: 80
# Container target port
targetPort: 8080
# Additional service annotations
annotations: {}
## Ingress configuration
ingress:
# Enable ingress
enabled: false
# Ingress class name
className: "nginx"
# Ingress annotations
annotations: {}
# cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/rate-limit: "100"
# Ingress hosts configuration
hosts:
- host: chart-example.local
paths:
- path: /
pathType: Prefix
# TLS configuration
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
## Resource limits and requests
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
## Horizontal pod autoscaling
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: 80
## Liveness probe configuration
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 3
## Readiness probe configuration
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
successThreshold: 1
failureThreshold: 3
## Pod Disruption Budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
## Node selection
nodeSelector: {}
## Tolerations
tolerations: []
## Affinity rules
affinity: {}
Grouping Related Values:
## Database configuration
database:
# Database host
host: "postgresql"
# Database port
port: 5432
# Database name
name: "myappdb"
# Use existing secret for credentials
existingSecret: ""
# Username (ignored if existingSecret is set)
username: "myappuser"
# Password (ignored if existingSecret is set)
password: ""
## Redis configuration
redis:
# Enable Redis deployment
enabled: true
# Redis host (ignored if enabled=true)
host: "redis-master"
# Redis port
port: 6379
# Use existing secret for password
existingSecret: ""
# Password (ignored if existingSecret is set)
password: ""
## Monitoring configuration
monitoring:
# Enable Prometheus metrics
enabled: false
# Service monitor for Prometheus Operator
serviceMonitor:
enabled: false
interval: 30s
scrapeTimeout: 10s
Security Best Practices
Implement security defaults in charts:
Pod Security Standards:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
# Run as non-root user
securityContext:
runAsNonRoot: true
runAsUser: {{ .Values.podSecurityContext.runAsUser | default 1000 }}
fsGroup: {{ .Values.podSecurityContext.fsGroup | default 1000 }}
seccompProfile:
type: {{ .Values.podSecurityContext.seccompProfile.type | default "RuntimeDefault" }}
containers:
- name: {{ .Chart.Name }}
securityContext:
# Prevent privilege escalation
allowPrivilegeEscalation: false
# Drop all capabilities
capabilities:
drop:
- ALL
# Read-only root filesystem
readOnlyRootFilesystem: true
# Run as non-root
runAsNonRoot: true
runAsUser: {{ .Values.securityContext.runAsUser | default 1000 }}
# Provide writable tmp directory
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Secret Management:
# Bad - hardcoded secrets
env:
- name: DATABASE_PASSWORD
value: "hardcoded-password" # Never do this!
# Good - reference existing secret
env:
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.database.existingSecret | default (printf "%s-db-secret" (include "myapp.fullname" .)) }}
key: password
# Create secret only if not using existing
{{- if not .Values.database.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "myapp.fullname" . }}-db-secret
type: Opaque
stringData:
password: {{ .Values.database.password | required "database.password is required when database.existingSecret is not set" }}
{{- end }}
Network Policies:
# templates/networkpolicy.yaml
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
# Allow ingress from ingress controller
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
ports:
- protocol: TCP
port: {{ .Values.service.targetPort }}
egress:
# Allow DNS
- to:
- namespaceSelector:
matchLabels:
name: kube-system
ports:
- protocol: UDP
port: 53
# Allow database access
- to:
- podSelector:
matchLabels:
app: postgresql
ports:
- protocol: TCP
port: 5432
{{- end }}
Resilience and High Availability
Build reliability into charts:
Pod Disruption Budget:
# templates/pdb.yaml
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
{{- if .Values.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
{{- else if .Values.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
{{- end }}
Anti-Affinity Rules:
# templates/deployment.yaml
affinity:
{{- if .Values.affinity }}
{{- toYaml .Values.affinity | nindent 8 }}
{{- else }}
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 12 }}
topologyKey: kubernetes.io/hostname
{{- end }}
Health Checks:
# templates/deployment.yaml
livenessProbe:
{{- if .Values.livenessProbe.exec }}
exec:
{{- toYaml .Values.livenessProbe.exec | nindent 4 }}
{{- else if .Values.livenessProbe.httpGet }}
httpGet:
{{- toYaml .Values.livenessProbe.httpGet | nindent 4 }}
{{- else if .Values.livenessProbe.tcpSocket }}
tcpSocket:
{{- toYaml .Values.livenessProbe.tcpSocket | nindent 4 }}
{{- end }}
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds | default 30 }}
periodSeconds: {{ .Values.livenessProbe.periodSeconds | default 10 }}
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds | default 5 }}
failureThreshold: {{ .Values.livenessProbe.failureThreshold | default 3 }}
successThreshold: {{ .Values.livenessProbe.successThreshold | default 1 }}
Configuration Management
Handle configuration changes gracefully:
ConfigMap Checksums:
# templates/deployment.yaml
metadata:
annotations:
# Force pod restart when ConfigMap changes
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
Immutable ConfigMaps for Production:
# templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "myapp.fullname" . }}-{{ .Release.Revision }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
immutable: true
data:
{{- range $key, $value := .Values.config }}
{{ $key }}: {{ $value | quote }}
{{- end }}
# Reference in deployment
volumes:
- name: config
configMap:
name: {{ include "myapp.fullname" . }}-{{ .Release.Revision }}
Upgrade Strategy
Plan for smooth upgrades:
Update Strategy:
# templates/deployment.yaml
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: {{ .Values.updateStrategy.rollingUpdate.maxSurge | default 1 }}
maxUnavailable: {{ .Values.updateStrategy.rollingUpdate.maxUnavailable | default 0 }}
Helm Hooks for Migrations:
# templates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-migration-{{ .Release.Revision }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
annotations:
# Run before upgrade
"helm.sh/hook": pre-upgrade,pre-install
# Set execution order (lower numbers run first)
"helm.sh/hook-weight": "1"
# Delete job after success
"helm.sh/hook-delete-policy": hook-succeeded
spec:
backoffLimit: 3
template:
metadata:
name: {{ include "myapp.fullname" . }}-migration
spec:
restartPolicy: Never
containers:
- name: migration
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command:
- /app/migrate
- up
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.database.existingSecret }}
key: url
Upgrade Documentation:
# templates/NOTES.txt
{{- if and .Release.IsUpgrade (ne .Release.Revision 1) }}
UPGRADE NOTES:
Upgrading from version {{ .Release.Revision | sub 1 }} to {{ .Release.Revision }}.
{{- if semverCompare ">=2.0.0" .Chart.Version }}
BREAKING CHANGES IN v2.0.0:
- The service port changed from 8080 to 80
- The ingress configuration structure changed
- Review values.yaml for new required fields
{{- end }}
To rollback if needed:
helm rollback {{ .Release.Name }} {{ .Release.Revision | sub 1 }}
{{- end }}
Testing
Include comprehensive tests:
Helm Test:
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "myapp.fullname" . }}-test-connection"
labels:
{{- include "myapp.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "myapp.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never
Run Tests:
# After installation
helm test myapp
# With cleanup
helm test myapp --logs
CI/CD Integration:
#!/bin/bash
# test-chart.sh
set -e
echo "Linting chart..."
helm lint ./myapp
echo "Testing template rendering..."
helm template myapp ./myapp --debug
echo "Installing chart in test namespace..."
helm install myapp ./myapp \
--namespace helm-test \
--create-namespace \
--wait \
--timeout 5m
echo "Running Helm tests..."
helm test myapp --namespace helm-test
echo "Cleanup..."
helm uninstall myapp --namespace helm-test
kubectl delete namespace helm-test
echo "All tests passed!"
Documentation
Provide comprehensive documentation:
README.md:
# MyApp Helm Chart
## Prerequisites
- Kubernetes 1.24+
- Helm 3.8+
- PV provisioner support (for persistence)
## Installing the Chart
\`\`\`bash
helm repo add mycompany https://charts.mycompany.com
helm install myapp mycompany/myapp
\`\`\`
## Configuration
| Parameter | Description | Default |
|-----------|-------------|---------|
| `replicaCount` | Number of replicas | `2` |
| `image.repository` | Container image repository | `mycompany/myapp` |
| `image.tag` | Container image tag | `Chart.appVersion` |
| `service.type` | Service type | `ClusterIP` |
| `ingress.enabled` | Enable ingress | `false` |
## Examples
### Basic Installation
\`\`\`bash
helm install myapp mycompany/myapp
\`\`\`
### With Ingress
\`\`\`bash
helm install myapp mycompany/myapp \
--set ingress.enabled=true \
--set ingress.hosts[0].host=myapp.example.com
\`\`\`
### Production Configuration
\`\`\`bash
helm install myapp mycompany/myapp \
-f values-production.yaml
\`\`\`
## Upgrading
\`\`\`bash
helm upgrade myapp mycompany/myapp
\`\`\`
## Uninstalling
\`\`\`bash
helm uninstall myapp
\`\`\`
Common Pitfalls
Over-Templating: Adding excessive flexibility makes charts complex and error-prone. Provide sensible defaults and template only what truly needs to vary.
Missing Required Values: Failing to validate required configuration causes cryptic errors. Use required function and provide clear error messages.
Ignoring Upgrades: Charts designed only for installation fail during upgrades. Test upgrade paths and handle configuration changes gracefully.
Weak Security Defaults: Defaulting to permissive security contexts creates vulnerabilities. Enforce security best practices by default.
Poor Documentation: Undocumented values force users to read templates. Document all values with descriptions and examples.
No Testing: Untested charts break in unexpected ways. Include Helm tests and integrate with CI/CD.
Key Takeaways
- Organize charts with clear structure separating core resources, helpers, documentation, and tests
- Use consistent naming conventions with helper functions for resource names and labels
- Document all values in values.yaml with comments explaining purpose and default behavior
- Enforce security defaults including non-root users, read-only filesystems, dropped capabilities, and seccomp profiles
- Reference existing secrets rather than creating them, validating required values with
requiredfunction - Implement Pod Disruption Budgets and anti-affinity rules for high availability
- Use ConfigMap checksums in deployment annotations to force pod restarts on configuration changes
- Plan for upgrades with rolling update strategies, pre-upgrade hooks for migrations, and upgrade documentation
- Include Helm tests in templates/tests/ directory and run them as part of CI/CD pipelines
- Provide comprehensive README with prerequisites, installation examples, configuration parameters, and upgrade/rollback procedures
- Use
helm lintandhelm template --debugto catch errors before deployment - Test upgrade paths, not just fresh installations, to ensure smooth version transitions
- Keep templates simple and readable - move complex logic to helper functions in _helpers.tpl
- Group related values logically in values.yaml for intuitive configuration discovery
- Balance flexibility with simplicity - template what varies across environments, hardcode stable patterns