Understanding Helm Charts
Helm chart structure explained. Chart.yaml, values.yaml, templates, built-in objects, helpers, dependencies, and how to debug a template that renders wrongly.
A chart is a directory with a specific layout. Helm reads it, renders the templates against a set of values, and applies the result to the cluster.
Everything below is either part of that layout or part of how the rendering works.
Structure
mychart/
├── Chart.yaml # metadata: name, version, dependencies
├── values.yaml # default configuration
├── charts/ # vendored subcharts
├── crds/ # CRDs, installed once and never upgraded
├── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── _helpers.tpl # reusable snippets, not rendered itself
│ └── NOTES.txt # printed after install
└── .helmignore
helm create mychart
That scaffolds a working chart with a Deployment, Service, ServiceAccount, Ingress and HPA. It is a reasonable starting point, though most of it should be deleted rather than carried around unused.
| Path | Purpose |
|---|---|
Chart.yaml | Name, version, appVersion, dependencies |
values.yaml | Defaults users override |
templates/ | Manifests rendered as Go templates |
templates/_helpers.tpl | Named templates; the leading underscore stops it being rendered as a manifest |
templates/NOTES.txt | Rendered and printed after install (use it for real next steps) |
charts/ | Subchart dependencies |
crds/ | CRDs installed before templates, never upgraded or deleted |
Chart.yaml
apiVersion: v2
name: payments-api
description: The payments service
type: application
version: 1.4.0 # the CHART version
appVersion: "2.7.1" # the APPLICATION version
dependencies:
- name: postgresql
version: "15.5.x"
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
version and appVersion are independently versioned and people mix them up constantly. version is the chart's own semantic version and must be bumped for every chart change, because repositories key on it. appVersion is the software being deployed and is usually the default image tag.
condition lets a user switch a dependency off entirely. Here, setting postgresql.enabled: false to point at a managed database instead.
Built-in objects
| Object | Contains |
|---|---|
.Values | Merged values: defaults plus user overrides |
.Release | .Name, .Namespace, .Revision, .IsInstall, .IsUpgrade |
.Chart | Contents of Chart.yaml |
.Capabilities | Cluster and API version information |
.Files | Non-template files in the chart |
.Template | The current template's own name and path |
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
spec:
replicas: {{ .Values.replicaCount }}
template:
spec:
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
.Capabilities is how a chart supports several Kubernetes versions at once:
{{- if .Capabilities.APIVersions.Has "autoscaling/v2" }}
apiVersion: autoscaling/v2
{{- else }}
apiVersion: autoscaling/v2beta2
{{- end }}
Template syntax you will actually use
Whitespace control. {{- trims whitespace before, -}} after. Without it, conditionals leave blank lines that break YAML indentation. The most common source of "invalid YAML" from a chart that looks fine.
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
{{- end }}
default and required. Give a fallback, or fail loudly with a useful message:
tag: {{ .Values.image.tag | default .Chart.AppVersion }}
host: {{ required "ingress.host is required when ingress is enabled" .Values.ingress.host }}
required is underused. Failing at render time with a clear sentence beats deploying something that comes up misconfigured.
quote and toYaml. Quote anything that might be read as a number or boolean, and use toYaml with nindent for nested blocks:
resources:
{{- toYaml .Values.resources | nindent 12 }}
nindent adds a newline and indents; indent does not add the newline. Mixing them up is a reliable way to produce broken YAML.
with and range:
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- range .Values.extraEnv }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
Inside with and range, . is rebound to the current item. To reach the root from inside one, use $: $.Release.Name.
Helpers
_helpers.tpl holds named templates so naming logic lives in one place:
{{- define "payments-api.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
metadata:
name: {{ include "payments-api.fullname" . }}
Two details. Use include, not template, include returns a string you can pipe into nindent or quote, while template cannot be piped. And that trunc 63 matters: Kubernetes names are capped at 63 characters, and a long release name plus a long chart name silently exceeds it.
Debugging
# Render locally, no cluster needed
helm template my-release ./mychart
# Render with your values, only the file you care about
helm template my-release ./mychart -f prod-values.yaml -s templates/deployment.yaml
# Catch structural problems
helm lint ./mychart
# Render against the live API server. Validates schema and CRDs
helm install my-release ./mychart --dry-run --debug
helm template is the command to reach for whenever output is not what you expect. Chart bugs are almost always visible in the rendered YAML, and reading it takes seconds compared to deploying and inspecting live objects.
helm lint catches missing required fields and malformed Chart.yaml, but it does not validate against the Kubernetes schema, --dry-run against a real cluster does.
Dependencies
helm dependency update ./mychart # fetch into charts/ and write Chart.lock
helm dependency list ./mychart
Commit Chart.lock so builds are reproducible. Whether you commit charts/ is a judgement call: vendoring makes installs work offline and pins exact content, at the cost of a larger repository.
Parent values reach a subchart by nesting under its name:
# parent values.yaml
postgresql:
enabled: true
auth:
database: payments
global is the escape hatch for values every subchart should see, such as a shared image registry.
Quick reference
| Task | Command |
|---|---|
| Scaffold | helm create <name> |
| Render locally | helm template <release> ./chart |
| Render one file | helm template <release> ./chart -s templates/deployment.yaml |
| Structural checks | helm lint ./chart |
| Validate against the cluster | helm install <r> ./chart --dry-run --debug |
| Fetch dependencies | helm dependency update ./chart |
| Package | helm package ./chart |
| Inspect defaults | helm show values ./chart |
Key takeaways
versionis the chart,appVersionis the software. Bumpversionon every chart change.{{-and-}}are not optional. Missing whitespace control is the usual cause of invalid rendered YAML.- Use
include, nottemplate, so the result can be piped intonindentorquote. nindentadds a newline,indentdoes not. Confusing them breaks indentation.requiredfails at render time with your message: far better than a misconfigured deploy.helm templatebefore anything else when output is wrong; the bug is visible in the rendered YAML.- CRDs in
crds/install once and are never upgraded.
Check your understanding
A helper that will not indent
A template uses {{ template "app.labels" . | nindent 4 }} and Helm fails with a parse error. What is wrong?
A chart update nobody receives
You fix a bug in a chart template and push it to your repository, bumping appVersion but leaving version unchanged. Users running helm repo update still get the old template. Why?
Frequently Asked Questions
What is the difference between version and appVersion in Chart.yaml?
version is the chart's own semantic version and identifies the package in a repository index; appVersion describes the software the chart deploys and is usually the default image tag. They move independently. Fixing a template bug bumps version while appVersion stays put, and deploying a new application release bumps appVersion and also requires a version bump because the chart changed. Forgetting to bump version is why a published fix never reaches users.
Why does my Helm template produce invalid YAML?
Almost always whitespace. Go template actions leave blank lines and stray indentation unless you trim them with {{- and -}}, and a conditional that emits nothing still leaves the line it occupied. The second frequent cause is confusing indent with nindent: nindent prepends a newline before indenting, indent does not, so using the wrong one shifts a whole block. Run helm template and read the rendered output. The broken indentation is visible immediately.
How do I debug a Helm chart without installing it?
helm template <release-name> ./chart -f values.yaml renders everything locally with no cluster contact, and -s templates/deployment.yaml narrows it to one file. Add helm lint for structural problems in Chart.yaml and missing required fields. When you need schema validation against real API versions and CRDs, helm install --dry-run --debug renders against the live API server without persisting anything, which catches errors local rendering cannot.
What is _helpers.tpl for?
It holds named templates, reusable snippets defined with define and invoked with include, and the leading underscore tells Helm not to treat the file as a manifest to render. The standard use is generating consistent names and label sets in one place so every resource in the chart agrees. Note the trunc 63 you will see in generated helpers: Kubernetes object names are limited to 63 characters, and a long release name combined with a long chart name will otherwise exceed it.
How do chart dependencies work?
Declare them in Chart.yaml under dependencies with a name, version range and repository, then run helm dependency update to fetch them into charts/ and generate Chart.lock. Values reach a subchart by nesting under its name in the parent's values.yaml, and global values are visible to every subchart. Adding a condition such as postgresql.enabled lets users disable a dependency entirely. Useful when some will run the bundled database and others point at a managed one. Commit Chart.lock for reproducible builds.
Should I use helm create as a starting point?
It is a reasonable scaffold and worth running once to see the conventions, but the generated chart includes a ServiceAccount, Ingress, HorizontalPodAutoscaler and a large values.yaml that most services do not need. Carrying unused templates makes a chart harder to read and review. Generate it, delete what your application genuinely does not use, and keep _helpers.tpl. The naming and label helpers it produces follow the recommended Kubernetes label conventions and are worth preserving.