Understanding Helm Charts

By Vishvesh Patel · DevOps EngineerUpdated September 12, 2026

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.

PathPurpose
Chart.yamlName, version, appVersion, dependencies
values.yamlDefaults users override
templates/Manifests rendered as Go templates
templates/_helpers.tplNamed templates; the leading underscore stops it being rendered as a manifest
templates/NOTES.txtRendered 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

ObjectContains
.ValuesMerged values: defaults plus user overrides
.Release.Name, .Namespace, .Revision, .IsInstall, .IsUpgrade
.ChartContents of Chart.yaml
.CapabilitiesCluster and API version information
.FilesNon-template files in the chart
.TemplateThe 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

TaskCommand
Scaffoldhelm create <name>
Render locallyhelm template <release> ./chart
Render one filehelm template <release> ./chart -s templates/deployment.yaml
Structural checkshelm lint ./chart
Validate against the clusterhelm install <r> ./chart --dry-run --debug
Fetch dependencieshelm dependency update ./chart
Packagehelm package ./chart
Inspect defaultshelm show values ./chart

Key takeaways

  • version is the chart, appVersion is the software. Bump version on every chart change.
  • {{- and -}} are not optional. Missing whitespace control is the usual cause of invalid rendered YAML.
  • Use include, not template, so the result can be piped into nindent or quote.
  • nindent adds a newline, indent does not. Confusing them breaks indentation.
  • required fails at render time with your message: far better than a misconfigured deploy.
  • helm template before 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

Exercise

A helper that will not indent

A template uses {{ template "app.labels" . | nindent 4 }} and Helm fails with a parse error. What is wrong?

Exercise

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.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Kubernetes Playground

Practise kubectl against a simulated cluster in your browser.

When it breaks

Related Resources