Helm Chart Best Practices: Structure, Versioning, and Templating
If you've spent any real time packaging Kubernetes applications with Helm, you know the difference between a chart that ages gracefully and one that becomes a maintenance nightmare in six months. The gap usually comes down to discipline around structure, versioning, and how you write your templates. Let's walk through the patterns that actually hold up in production.
Chart Structure: Don't Wing It
The default helm create scaffold is fine as a starting point, but most teams deviate from it in messy ways. Here's the structure I settle on for most production charts:
mychart/
├── Chart.yaml
├── values.yaml
├── values-staging.yaml
├── values-production.yaml
├── charts/ # subcharts / dependencies
├── templates/
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ ├── secret.yaml
│ ├── hpa.yaml
│ ├── serviceaccount.yaml
│ ├── NOTES.txt
│ └── tests/
│ └── test-connection.yaml
└── .helmignore
The values-staging.yaml and values-production.yaml files aren't standard Helm convention — but keeping environment overrides inside the chart repo makes it easy to version them alongside the chart itself. You apply them with -f values-production.yaml at install time.
Chart.yaml Non-Negotiables
apiVersion: v2
name: myapp
description: Application serving the core API
type: application
version: 1.4.2 # chart version — bump this on every change
appVersion: "3.2.1" # the app version — matches your container image tag
maintainers:
- name: platform-team
email: [email protected]
keywords:
- api
- backend
sources:
- https://github.com/yourorg/myapp
version and appVersion serve different purposes. version is the chart package version — it follows semver and should change any time you modify templates, defaults, or dependencies. appVersion is just metadata describing what app version the chart was designed for. Don't conflate them.
Versioning Strategy
Follow semver strictly and automate the bumps:
| Change Type | Version Bump | Example |
|---|---|---|
| Breaking values change, template restructure | MAJOR | 1.x.x → 2.0.0 |
| New optional feature, new default | MINOR | 1.4.x → 1.5.0 |
| Bug fix, doc update, minor template tweak | PATCH | 1.4.2 → 1.4.3 |
The most common mistake is treating chart releases casually. Once a chart version is published to a registry, it's immutable. Don't reuse versions. Tag your git commits:
# after bumping version in Chart.yaml
git tag helm/myapp-1.4.2
git push origin helm/myapp-1.4.2
If you're using a CI pipeline (you should be), automate chart version bumping with helm-docs and chart-testing:
# install chart-testing (ct)
brew install helm/tap/chart-testing
# lint and validate
ct lint --config ct.yaml
# run integration tests against a kind cluster
ct install --config ct.yaml
Templating Patterns That Scale
Use _helpers.tpl for Everything Repetitive
{{/* Standard labels applied to all resources */}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/* Selector labels — stable, don't change after first deploy */}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Using app.kubernetes.io/ labels consistently is important — many Kubernetes ecosystem tools (Argo CD, Kiali, kubectl) use these to group and display resources.
Conditionals: Keep Them Readable
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" . }}
{{- 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 "myapp.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
Notice the $ when you're inside a range loop and need to access the root context. This trips up a lot of people.
Values Schema Validation
Add a values.schema.json to your chart root. Helm will validate user-supplied values against it before rendering:
{
"$schema": "http://json-schema.org/draft-07/schema",
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1,
"description": "Number of pod replicas"
},
"image": {
"type": "object",
"required": ["repository", "tag"],
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": {
"type": "string",
"enum": ["Always", "IfNotPresent", "Never"]
}
}
}
}
}
This saves you from mysterious runtime failures when someone passes replicaCount: "three" instead of 3.
Values File Design
Good values.yaml design is as important as the templates themselves. The defaults should work out of the box for a dev environment:
replicaCount: 1
image:
repository: myorg/myapp
pullPolicy: IfNotPresent
tag: "" # defaults to Chart.appVersion if empty
serviceAccount:
create: true
annotations: {}
name: ""
podAnnotations: {}
podSecurityContext: {}
securityContext: {}
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: chart-example.local
paths:
- path: /
pathType: Prefix
resources:
limits:
cpu: 500m
memory: 128Mi
requests:
cpu: 100m
memory: 64Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
One pattern I always follow: never use required in values.yaml for fields the user must supply at install time. Instead, use the required template function so the error is caught at render time with a clear message:
# In your deployment.yaml template
image: {{ required "image.repository is required" .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
Linting and Testing Before You Ship
Set up a ct.yaml in your repo root for chart-testing config:
remote: origin
target-branch: main
chart-dirs:
- charts
chart-repos:
- bitnami=https://charts.bitnami.com/bitnami
helm-extra-args: --timeout 600s
Run the full validation locally before pushing:
# Lint
helm lint charts/myapp --strict
# Render and inspect
helm template myapp charts/myapp -f charts/myapp/values-staging.yaml | kubectl apply --dry-run=client -f -
# Check for deprecated APIs
helm template myapp charts/myapp | kubeval --strict
# Security scan
helm template myapp charts/myapp | trivy config -
The kubectl apply --dry-run=client step is underrated — it catches schema mismatches that helm lint misses because lint only checks Helm syntax, not Kubernetes API validity.
Packaging and Publishing
Once your chart is solid:
# Package it
helm package charts/myapp
# Push to OCI registry (Helm 3.8+)
helm push myapp-1.4.2.tgz oci://ghcr.io/yourorg/charts
# Or push to a traditional chart repo
helm repo index . --url https://charts.yourorg.com
# then upload to S3/GCS/GitHub Pages
OCI-based registries are the direction Helm is moving. If you're starting fresh, use OCI — it integrates cleanly with existing container registries and avoids running a separate chart museum.
Getting chart structure and versioning right upfront is one of those investments that pays off quietly. You won't notice it when things are going well, but you'll absolutely notice the absence of discipline when you're trying to debug a production incident and your charts are a maze of undocumented overrides and undated versions.
Was this article helpful?
Related Articles
Helm Hooks and Chart Tests: Lifecycle Management Done Right
Learn how to use Helm hooks for database migrations and pre-deploy tasks, and chart tests to validate releases in Kubernetes.
Helmfile: Manage Multiple Helm Releases Like a Pro
Use Helmfile to declaratively manage multiple Helm releases across environments, with layered values, dependencies, and GitOps-friendly workflows.
Helm Error: Release Has No Deployed Revisions - How To Recover Failed Installations
If you've been working with Helm for any length of time, you've probably encountered this frustrating error: "Error: release has no deployed revisions." It...
Helm Error: Repository Name Already Exists - Complete Fix Guide For Duplicate Repo Names
If you've been working with Helm for more than five minutes, you've probably encountered this frustrating error: "repository name already exists". It's one...
Fix Helm 'Another Operation in Progress' Error
Resolve the Helm 'another operation (install/upgrade/rollback) is in progress' error caused by stuck releases with safe recovery steps.
Production-Ready Helm Charts: Templates, Values, Hooks, and Testing
Battle-tested patterns for writing Helm charts that survive production — covering values design, template structure, lifecycle hooks, and chart testing.