Chart Design
Summary
- Chart design is the practice of structuring a Helm chart so it is reusable, versioned, and safe to upgrade.
- Insight: A good chart exposes a small, well-documented values surface and hides templating complexity behind named helpers.
- Key Concepts: subcharts,
_helpers.tpl,include, named templates,values.schema.json, chart vs app version, hooks. - When to Use: Whenever you package software for reuse across teams, environments, or external consumers.
- Limitations: Deep subchart trees and heavy template logic quickly become hard to reason about and review.
- Related: Templating philosophy, Helm basics, Kustomize post-rendering, best practices.
Recipe
Start from the scaffold, then shape it deliberately.
helm create myappDefine your values surface in values.yaml, factor naming into _helpers.tpl, add a values.schema.json to validate input, pin subcharts in Chart.yaml, and version the chart with semver on every change.
Working Example
A reusable helper for consistent names and labels lives in templates/_helpers.tpl.
# templates/_helpers.tpl
{{- define "app.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "app.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}The Deployment consumes those helpers with include, keeping templates thin.
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "app.fullname" . }}
labels:
{{- include "app.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
resources:
{{- toYaml .Values.resources | nindent 12 }}
readinessProbe:
httpGet:
path: /healthz
port: 8080A JSON schema rejects bad input before rendering.
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["replicaCount", "image"],
"properties": {
"replicaCount": { "type": "integer", "minimum": 1 },
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" }
}
}
}
}Deep Dive
Named templates and include
Use define in _helpers.tpl to name a reusable block, then call it with include "name" ..
Prefer include over the older template action because include returns a string you can pipe into nindent for correct indentation.
Centralize names and labels in helpers so every object stays consistent and the recommended app.kubernetes.io/* labels appear everywhere.
Subcharts and dependencies
Declare dependencies in Chart.yaml, then run helm dependency update to vendor them into charts/ and write Chart.lock.
# Chart.yaml
dependencies:
- name: redis
version: "20.1.x"
repository: https://charts.example.com
condition: redis.enabled
alias: cacheParent values flow to a subchart under a key matching its name (or alias), so cache.architecture in the parent sets the aliased Redis subchart.
Use condition to let consumers disable a subchart, and alias when you need two instances of the same chart.
Global values (.Values.global.*) are shared across the parent and all subcharts, which is the correct place for cross-cutting settings like an image registry.
Chart version vs app version
version in Chart.yaml is the chart's own semver and must be bumped on any chart change.
appVersion tracks the deployed application and is informational; use it as the default image tag via .Chart.AppVersion.
Bump version for template or default changes, and bump appVersion when the packaged application releases a new build.
Hooks for lifecycle steps
Annotate a resource with helm.sh/hook to run it at a phase, such as a migration Job before upgrade.
# templates/migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "app.fullname" . }}-migrate
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["/app/migrate"]hook-weight orders multiple hooks, and hook-delete-policy controls cleanup so old hook Jobs do not accumulate.
Gotchas
Whitespace bugs are the most common failure; use {{- and -}} to trim, and always pipe include output through nindent at the correct depth.
Forgetting to bump version breaks GitOps tools that key on chart version to detect drift; automate the bump in CI.
Overusing subcharts creates deep value-override chains that are hard to trace; flatten where a subchart adds no real reuse.
Leaving helm.sh/hook-delete-policy unset leaves stale hook resources behind after every release.
Putting environment-specific defaults in values.yaml couples the chart to one site; keep the chart generic and pass env values from overlay files.
Alternatives
Kustomize suits teams that own their manifests and want overlays instead of a templating language; see the Kustomize page.
Plain manifests plus a thin kubectl apply work for tiny, single-environment services where a chart is overkill.
A library chart (type: library) packages only helpers for other charts to import, with no installable resources of its own.
CUE or jsonnet-based tools offer stronger typing than Go templates, at the cost of a smaller ecosystem and steeper learning curve.
FAQs
Should the chart version equal the app version? No. The chart version tracks chart changes; appVersion tracks the application. They move independently.
How do I share config with subcharts? Use .Values.global.* for cross-cutting values, or set subchart values under the subchart's name key.
Where do reusable snippets go? In _helpers.tpl as named define blocks, invoked with include.
How do I validate user input? Ship a values.schema.json; Helm validates supplied values against it at install and upgrade.
When should I use a hook? For lifecycle steps like migrations or backups that must run before or after the main install or upgrade.
Can I run two copies of one subchart? Yes, declare it twice with different alias values.
Related
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).