Top Helm Chart Best Practices for Kubernetes
Top Helm Chart Best Practices for Kubernetes
Helm charts promise reusable Kubernetes application packaging, but poorly designed charts create more problems than they solve. A chart that hardcodes values becomes useless across environments. A chart with insufficient defaults forces users to specify dozens of configuration options just to get started. A chart without proper testing might deploy successfully but fail at runtime in subtle ways. The difference between a useful chart and technical debt is deliberate design following established patterns.
This guide covers battle-tested practices for building maintainable, secure, and user-friendly Helm charts. You'll learn template organization patterns, values file design, testing strategies, security configurations, and documentation approaches that make charts genuinely reusable across development, staging, and production environments. These practices come from maintaining charts in production at scale—not theoretical best-case scenarios.
We'll explore chart structure, template best practices, values design, versioning strategy, testing approaches, security considerations, and common antipatterns to avoid.
Chart Structure and Organization
Well-structured charts follow predictable organization that makes them easy to understand and modify. The standard Helm chart structure provides a foundation, but deliberate template organization within that structure separates maintainable charts from tangled messes.
Standard Chart Layout
mychart/ ├── Chart.yaml # Chart metadata ├── values.yaml # Default values ├── values.schema.json # JSON schema for values validation ├── charts/ # Dependency charts ├── templates/ │ ├── NOTES.txt # Post-install instructions │ ├── _helpers.tpl # Template helpers │ ├── deployment.yaml │ ├── service.yaml │ ├── ingress.yaml │ ├── serviceaccount.yaml │ ├── configmap.yaml │ ├── secret.yaml │ ├── hpa.yaml │ └── tests/ │ └── test-connection.yaml ├── .helmignore # Files to exclude from package └── README.md # Chart documentation
The templates/ directory should contain one file per Kubernetes resource type in most cases. Large charts benefit from subdirectories grouping related resources, but avoid excessive nesting that makes templates hard to locate.
Template Helpers Pattern
The _helpers.tpl file defines reusable template snippets. Every chart should include standard helpers for common patterns:
{{/*
Expand the name of the chart.
*/}}
{{- define "mychart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "mychart.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 }}
{{/*
Common labels
*/}}
{{- define "mychart.labels" -}}
helm.sh/chart: {{ include "mychart.chart" . }}
{{ include "mychart.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "mychart.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "mychart.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "mychart.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
These helpers ensure consistent naming and labeling across all resources. Using template functions instead of repeating logic in each resource file makes charts easier to maintain and reduces errors.
Values File Design Principles
The values.yaml file defines the chart's API—how users configure deployments. Well-designed values files balance sensible defaults with necessary flexibility, use clear naming conventions, and provide documentation through comments.
Hierarchical Value Organization
Group related values hierarchically to prevent namespace pollution:
# Good: Organized hierarchically
image:
repository: nginx
pullPolicy: IfNotPresent
tag: "1.25"
service:
type: ClusterIP
port: 80
annotations: {}
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
# Bad: Flat structure
imageRepository: nginx
imagePullPolicy: IfNotPresent
imageTag: "1.25"
serviceType: ClusterIP
servicePort: 80
cpuLimit: 200m
memoryLimit: 256Mi
Hierarchical organization makes values easier to override selectively and improves readability. Users can override entire sections or individual keys: --set image.tag=1.26 is clearer than --set imageTag=1.26.
Sensible Defaults Strategy
Provide defaults that work for development but allow production hardening:
replicaCount: 1 # Single replica for dev, override for prod
image:
repository: myapp
pullPolicy: IfNotPresent # Works locally, use Always in prod
tag: "" # Empty defaults to chart appVersion
# Resources disabled by default but documented
resources: {}
# limits:
# cpu: 200m
# memory: 256Mi
# requests:
# cpu: 100m
# memory: 128Mi
# Security contexts off by default for compatibility
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
# Autoscaling disabled by default
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 80
This pattern enables quick starts while showing users what they should configure for production. Commented examples serve as inline documentation.
Value Documentation
Document every value with comments explaining purpose and valid options:
# Number of pod replicas to deploy
# Ignored if autoscaling.enabled is true
replicaCount: 1
# Image configuration
image:
# Container image repository
repository: nginx
# Image pull policy. Options: Always, IfNotPresent, Never
pullPolicy: IfNotPresent
# Image tag. Defaults to chart appVersion if not specified
tag: ""
# Service configuration
service:
# Service type. Options: ClusterIP, NodePort, LoadBalancer
type: ClusterIP
# Service port
port: 80
# Service annotations (e.g., for cloud provider load balancers)
annotations: {}
Template Best Practices
Conditional Resource Creation
Make optional resources truly optional by wrapping entire templates in conditionals:
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "mychart.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
The top-level conditional prevents creating an empty Ingress resource if ingress.enabled is false. This pattern applies to all optional resources: HPA, ServiceMonitor, PodDisruptionBudget, etc.
Safe Value Access with Default
Always provide fallbacks when accessing potentially undefined values:
# Bad: Fails if tag isn't set
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
# Good: Falls back to chart appVersion
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
# Good: Safe annotation handling
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
The with action creates a scope where . refers to the specified value, and only executes the block if the value is non-empty. This pattern prevents creating empty YAML keys.
Resource Limits and Requests
Make resource configuration optional but easy to enable:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
This pattern omits the resources key entirely if not specified, avoiding validation errors from empty resource definitions while allowing full control when configured.
toYaml for complex nested structures instead of trying to template every field individually. This preserves exact user input and handles deep nesting naturally.
Version and Dependency Management
Semantic Versioning for Charts
Chart versions should follow semantic versioning strictly:
- Major version: Breaking changes to values schema or behavior
- Minor version: New features, backward-compatible changes
- Patch version: Bug fixes, documentation updates
# Chart.yaml apiVersion: v2 name: mychart description: A Helm chart for my application type: application version: 1.2.3 # Chart version appVersion: "2.0.1" # Application version # Chart version changes: # 1.2.3 -> 1.2.4: Bug fix in template # 1.2.3 -> 1.3.0: Add new optional resource # 1.2.3 -> 2.0.0: Rename required value key
Separate chart version from application version. The appVersion reflects what application version deploys by default; the chart version reflects the chart's template evolution.
Dependency Management
Declare chart dependencies explicitly in Chart.yaml:
dependencies:
- name: postgresql
version: "12.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
tags:
- database
- name: redis
version: "~17.0.0"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled
Use condition fields to make dependencies optional. Users can disable dependencies by setting postgresql.enabled=false if they use external databases.
Update dependencies before packaging:
helm dependency update ./mychart helm package ./mychart
Security Best Practices
Pod Security Standards
Provide secure defaults while maintaining compatibility:
# values.yaml
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
# Template usage
spec:
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
These settings align with Kubernetes Pod Security Standards at the "restricted" level—the most secure baseline. Applications requiring specific capabilities should document why in values.yaml comments.
Secret Management
Never hardcode secrets in charts. Provide multiple approaches for secret injection:
# values.yaml
secrets:
# Option 1: Reference existing secret
existingSecret: ""
# Option 2: Create secret from values (for dev only)
create: false
data: {}
# apiKey: ""
# dbPassword: ""
# Template
{{- if and .Values.secrets.create (not .Values.secrets.existingSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "mychart.fullname" . }}
type: Opaque
data:
{{- range $key, $value := .Values.secrets.data }}
{{ $key }}: {{ $value | b64enc | quote }}
{{- end }}
{{- end }}
# In deployment
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.secrets.existingSecret | default (include "mychart.fullname" .) }}
key: apiKey
This pattern supports both creating secrets from values (convenient for development) and referencing pre-created secrets (required for production). Never commit values files with actual secret data to version control.
RBAC Configuration
Include ServiceAccount and RBAC resources when the application needs Kubernetes API access:
# values.yaml
serviceAccount:
create: true
annotations: {}
name: ""
rbac:
create: true
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
# serviceaccount.yaml
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "mychart.serviceAccountName" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
# role.yaml
{{- if .Values.rbac.create -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "mychart.fullname" . }}
rules:
{{- toYaml .Values.rbac.rules | nindent 2 }}
{{- end }}
Testing Helm Charts
Template Validation
Basic validation ensures templates render without errors:
# Render templates locally helm template mychart ./mychart # Render with specific values helm template mychart ./mychart -f values-prod.yaml # Lint chart for common issues helm lint ./mychart # Dry-run installation helm install mychart ./mychart --dry-run --debug
Linting catches common problems: missing required values, invalid template syntax, improper indentation, and resource naming issues.
Helm Test Hooks
Include test resources that verify deployment success:
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "mychart.fullname" . }}-test-connection"
labels:
{{- include "mychart.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "mychart.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never
Run tests after installation:
helm install mychart ./mychart helm test mychart
Tests verify that services are accessible and responding. Include tests that check database connectivity, API endpoints, or any other critical functionality.
Values Schema Validation
Define a JSON schema to validate user-provided values:
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1
},
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": {
"type": "string"
},
"tag": {
"type": "string"
},
"pullPolicy": {
"type": "string",
"enum": ["Always", "IfNotPresent", "Never"]
}
}
},
"service": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["ClusterIP", "NodePort", "LoadBalancer"]
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
}
}
}
}
Helm validates values against this schema automatically during installation, catching configuration errors before deployment.
Documentation Standards
NOTES.txt for Post-Install Instructions
The NOTES.txt template displays instructions after installation:
# templates/NOTES.txt
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mychart.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "mychart.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "mychart.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "mychart.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
These instructions adapt to the user's configuration, showing how to access the deployed application based on service type and ingress settings.
Comprehensive README
Every chart needs a README.md covering:
- What the chart deploys and what it's for
- Prerequisites (Kubernetes version, required cluster features)
- Installation instructions with examples
- Configuration table documenting all values
- Examples for common deployment scenarios
- Upgrade instructions and notes about breaking changes
- Troubleshooting guidance
Common Antipatterns to Avoid
Antipattern: Overengineering Flexibility
Charts that try to support every possible configuration become unmaintainable:
# Bad: Too flexible
deployment:
enabled: true
replicas: 1
podSpec:
containers:
- name: app
image: nginx
ports:
- containerPort: 80
# Users define entire container spec...
Better: Provide sensible configuration points without exposing entire Kubernetes API. Use values for common customization needs; users needing extreme flexibility can fork the chart.
Antipattern: Implicit Dependencies
Charts that assume external resources exist without documenting:
# Bad: Assumes cert-manager is installed apiVersion: cert-manager.io/v1 kind: Certificate # No conditional, no documentation about requirement
Better: Make optional integrations conditional and document prerequisites clearly. If cert-manager is optional, wrap resources in {{- if .Values.certManager.enabled }}.
Antipattern: Mutable Default Tags
Using latest or other mutable tags as defaults:
# Bad: Mutable tag image: tag: latest # Good: Immutable tag or empty (uses appVersion) image: tag: "" # Defaults to chart appVersion
Mutable tags create non-reproducible deployments. The same chart version might deploy different application versions at different times.
Antipattern: Hardcoded Values
Values that should be configurable but aren't:
# Bad: Hardcoded namespace
namespace: production
# Good: Use Release.Namespace
namespace: {{ .Release.Namespace }}
# Bad: Hardcoded resource names
name: my-app-deployment
# Good: Generated from release name
name: {{ include "mychart.fullname" . }}
Publishing and Distribution
Packaging Charts
# Package chart helm package ./mychart # Output: mychart-1.2.3.tgz # Generate index for chart repository helm repo index . --url https://charts.example.com # Output: index.yaml
Chart Repositories
Host charts in HTTP-accessible repositories. Popular options:
- GitHub Pages: Free, simple, version-controlled
- ChartMuseum: Dedicated chart repository server
- Harbor: Full registry with chart support
- Cloud storage: S3, GCS, Azure Blob with static hosting
GitHub Pages Example
# In gh-pages branch charts/ ├── mychart-1.2.3.tgz ├── mychart-1.2.2.tgz └── index.yaml # Users add repository helm repo add myrepo https://username.github.io/helm-charts helm repo update helm install myapp myrepo/mychart
Upgrade and Migration Strategies
Breaking Changes
When making breaking changes, increment major version and document migration path:
# In README.md or UPGRADING.md ## Upgrading to 2.0.0 ### Breaking Changes 1. `database.host` renamed to `postgresql.host` - Old: `database.host=postgres.local` - New: `postgresql.host=postgres.local` 2. ServiceAccount name generation changed - Custom names now require `serviceAccount.name` - Automatic generation uses new format ### Migration Steps ```bash # Export current values helm get values myrelease > old-values.yaml # Update values file according to breaking changes # Upgrade with updated values helm upgrade myrelease mychart --version 2.0.0 -f new-values.yaml ```
Helm Hooks for Migrations
Use hooks to run migration jobs during upgrades:
apiVersion: batch/v1
kind: Job
metadata:
name: "{{ include "mychart.fullname" . }}-migration"
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migration
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["./migrate.sh"]
This job runs before the upgrade applies, allowing database schema migrations or configuration updates before new application versions deploy.
Frequently Asked Questions
Should I use subcharts or separate charts for microservices?
Separate charts provide better version independence and reusability. Use an umbrella chart that lists microservice charts as dependencies if you want single-command deployment of the full stack. Subcharts work well for tightly coupled components that always deploy together, but independent charts suit most microservice architectures better.
How do I test charts against multiple Kubernetes versions?
Use Kind or similar tools in CI/CD to create clusters with different Kubernetes versions, then run helm install and helm test against each. This catches API deprecations before they affect production. GitHub Actions example: create matrix builds for Kubernetes 1.25, 1.26, 1.27, testing your chart against each.
What's the best way to handle environment-specific values?
Maintain separate values files per environment: values-dev.yaml, values-staging.yaml, values-prod.yaml. Use helm install -f values-prod.yaml to apply environment-specific overrides. Keep sensitive values out of version control; load them from secret management systems at deployment time. Some teams use Helmfile or ArgoCD to manage environment-specific configurations.
Should charts create namespaces?
No. Let users or cluster administrators create namespaces outside the chart. Charts should deploy to the namespace specified with --namespace, not create namespaces themselves. This separation of concerns prevents permission issues and gives administrators control over namespace-level policies.
How do I handle init containers in charts?
Make initContainers configurable through values, providing sensible defaults for common patterns like waiting for dependencies. Use conditional logic to only include initContainers if configured, since not all deployments need them.
What's the recommended approach for database initialization?
Use initContainers or Helm hooks (pre-install, pre-upgrade) to run database migrations. Never embed database credentials in charts; reference existing secrets or use secret management integrations. Document that users must create required databases and secrets before installation.
How should I version application images vs chart versions?
Application image tags go in appVersion field of Chart.yaml and default in values.yaml. Chart version reflects template changes independently. This lets users deploy different application versions using the same chart version, or upgrade chart templates without changing application version. Keep them separate.
Can I use Helm charts with GitOps tools?
Yes. ArgoCD and Flux both support Helm charts natively. They can watch Helm repositories, apply charts with custom values, and manage upgrades automatically. This combines Helm's templating with GitOps workflow. The tools render charts and apply resulting manifests while tracking state in Git.
How do I debug template rendering issues?
Use helm template --debug to see rendered output and error messages. Add --show-only templates/deployment.yaml to isolate specific templates. Use helm lint for common issues. For complex problems, render templates to files and examine the YAML directly: helm template mychart > output.yaml.
Should I use Helm for everything?
No. Simple applications with few variations might not need templating. Plain Kubernetes manifests with Kustomize provide simpler configuration for straightforward cases. Use Helm when you need: multiple deployment scenarios, reusable packages, version management of application + configuration, or distribution to other teams. Don't use Helm just because it exists.
Conclusion
Well-designed Helm charts balance flexibility with simplicity, provide secure defaults while allowing production hardening, and include comprehensive documentation that makes them genuinely reusable. The practices outlined here—hierarchical values organization, defensive template patterns, comprehensive testing, and clear documentation—separate professional charts from hastily assembled templates.
Start with the standard chart structure and helpers. Design values files that work out-of-the-box while documenting production requirements. Implement security best practices by default. Test charts across multiple scenarios. Document everything users need to know.
The investment in proper chart design pays dividends every time someone deploys your application—whether that's your future self, teammates, or external users. Good charts make Kubernetes deployments reproducible, configurable, and reliable.