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