Chart Anatomy

What every file in a chart is for, why version and appVersion move independently, and the two directories with special rules — charts/ and crds/.

beginner 18 min lesson hands-on task included

helm create gives you a working chart in one command and about 180 lines of scaffolding you did not write. Reading it once, properly, is the fastest way to learn what a chart is — and deleting the parts you do not need is the second fastest.


Topic 1: The Files

EVERY FILE IN A CHART HAS ONE JOB mychart/ ├─ Chart.yaml name, version, appVersion, dependencies — the package identity ├─ values.yaml DEFAULTS, and the documentation of your interface ├─ values.schema.json validation — rejects bad input before rendering ├─ templates/ ├─ deployment.yaml rendered into the release ├─ _helpers.tpl named templates; the underscore means "do not render me" ├─ NOTES.txt printed after install — put the real next step here ├─ tests/ helm test pods, annotated as a test hook ├─ charts/ vendored subcharts (helm dependency update writes here) ├─ crds/ installed first, NEVER upgraded or deleted by Helm ├─ .helmignore what to leave out of the packaged .tgz crds/ IS A ONE-WAY DOOR Installed once, never upgraded, never deleted. Plan CRD updates outside Helm. version vs appVersion version = the CHART's semver. appVersion = the software it ships. They move independently.
Two directories have rules that differ from everything else: charts/ is vendored output rather than something you edit, and crds/ is installed once and never touched again.
helm create demo
demo/
├── Chart.yaml            identity: name, version, appVersion, dependencies
├── values.yaml           DEFAULTS — and the documentation of your interface
├── values.schema.json    optional validation, rejects bad input early
├── templates/
│   ├── deployment.yaml   rendered into the release
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── hpa.yaml
│   ├── serviceaccount.yaml
│   ├── _helpers.tpl      named templates — the underscore means "do not render"
│   ├── NOTES.txt         printed after install
│   └── tests/
│       └── test-connection.yaml
├── charts/               vendored subcharts (written by dependency update)
├── crds/                 installed first, never upgraded, never deleted
└── .helmignore           what to exclude from the packaged .tgz

Everything in templates/ is rendered unless its filename starts with _ or .. That is the whole rule — there is no manifest listing files, no ordering configuration. Helm renders them all and sorts the results by kind before applying.

.helmignore works like .gitignore and matters more than it looks: without it, helm package sweeps up your .git directory, local values-*.yaml files, and anything else in the folder. A packaged chart containing a developer’s values-local.yaml with a password in it is a real way secrets travel.


Topic 2: Chart.yaml

apiVersion: v2                    # v2 = Helm 3. v1 charts still install, but are legacy.
name: checkout
description: The checkout service
type: application                 # or "library" — see the dependencies lesson
version: 0.4.1                    # the CHART's version. Semver, required.
appVersion: "1.6.0"               # the SOFTWARE's version. A string, quoted.
kubeVersion: ">=1.27.0-0"         # refuse to install on older clusters
home: https://github.com/acme/checkout
sources: [https://github.com/acme/checkout]
maintainers:
  - name: platform-team
    email: platform@acme.example
annotations:
  artifacthub.io/changes: |
    - kind: fixed
      description: readiness probe timeout
dependencies:
  - name: postgresql
    version: "15.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled

version and appVersion are independent, and confusing them causes real problems. version is the chart’s own semver — bump it whenever the chart changes, because that is what consumers pin and what the packaged filename uses. appVersion is the software the chart ships, and it is what the default image tag usually follows.

A template change with no application change bumps version only. A new application release with no chart change bumps appVersion and — because the chart content changed — version too. Charts that never bump version cannot be pinned, cached or rolled back by consumers.

kubeVersion is a cheap guard worth setting: it fails the install with a clear message instead of rendering an API version the cluster does not have.


Topic 3: values.yaml Is Your Public Interface

The single most consequential file for whoever uses your chart. It serves three purposes at once: defaults, documentation, and the list of things that are configurable at all.

# -- Number of replicas. Ignored when autoscaling.enabled is true.
replicaCount: 1

image:
  repository: ghcr.io/acme/checkout
  # -- Overrides the image tag. Defaults to .Chart.AppVersion.
  tag: ""
  pullPolicy: IfNotPresent

# -- Resource requests and limits. Set these; the defaults are deliberately small.
resources:
  requests: { cpu: 100m, memory: 128Mi }
  limits:   { memory: 256Mi }

ingress:
  enabled: false
  className: nginx
  hosts:
    - host: checkout.example.com
      paths: [{ path: /, pathType: Prefix }]

postgresql:
  enabled: true          # a SUBCHART's values live under its name

Four rules that make the difference between a chart people can use and one they fork:

  • Every key that exists in a template must exist in values.yaml, even as "" or {}. A key that only appears in a template is invisible to anyone reading the interface.
  • Comment every non-obvious key, in the # -- style that documentation generators (helm-docs) pick up. The chart’s README should be generated, not maintained by hand.
  • Defaults must be safe, not production-sized. One replica, small resources, ingress disabled. A default that provisions a LoadBalancer costs somebody money on their first helm install.
  • Prefer enabled: true/false booleans over presence checks. if .Values.ingress.enabled is legible; if .Values.ingress breaks the moment somebody sets an empty map.

values.schema.json turns the interface into something enforced rather than hoped for:

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["image"],
  "properties": {
    "replicaCount": { "type": "integer", "minimum": 0 },
    "image": {
      "type": "object",
      "required": ["repository"],
      "properties": {
        "repository": { "type": "string" },
        "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }
      }
    }
  }
}

Helm validates supplied values against it before rendering, so a typo becomes a clear error at install time rather than a missing field in a running Deployment.


Topic 4: _helpers.tpl and the Standard Labels

Anything in templates/ starting with _ is not rendered as a manifest — it defines named templates other files include.

{{/* The chart name, overridable */}}
{{- define "checkout.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/* A fully qualified name: release + chart, truncated to fit a label */}}
{{- define "checkout.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name (include "checkout.name" .) | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}

The trunc 63 | trimSuffix "-" idiom appears in every generated chart for a real reason: Kubernetes label values are limited to 63 characters, and a long release name plus a long chart name exceeds it. Truncating can leave a trailing hyphen, which is invalid, so the suffix is trimmed. Copy this pattern rather than reinventing it.

The label helpers matter just as much:

{{- define "checkout.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{ include "checkout.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "checkout.selectorLabels" -}}
app.kubernetes.io/name: {{ include "checkout.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Selector labels are deliberately a smaller set, and this is not a style choice: a Deployment’s .spec.selector is immutable. If the version-carrying labels were in the selector, every application upgrade would require deleting and recreating the Deployment. Keeping selectorLabels free of anything that changes is what makes upgrades possible at all.


Topic 5: NOTES.txt and Tests

NOTES.txt is rendered like any template and printed after install. Most charts waste it on ASCII art. Use it for the real next step:

Checkout {{ .Chart.AppVersion }} is installed as {{ .Release.Name }}.

Watch it come up:
  kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }} -w

{{- if not .Values.ingress.enabled }}
No ingress is enabled. Reach it locally with:
  kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "checkout.fullname" . }} 8080:{{ .Values.service.port }}
{{- end }}

templates/tests/ holds pods annotated as test hooks, run on demand by helm test. The generated one only checks that the service resolves; a useful one checks that the application answers correctly, and it costs ten lines. Chart tests get their own treatment in the hooks lesson.


Topic 6: Deleting the Scaffolding

helm create is a starting point, not a template to preserve. A chart that ships every generated file unchanged tells a reviewer nothing about what the service actually needs.

Keep and edit      deployment, service, _helpers, values, NOTES, tests
Keep if you use it ingress, hpa, serviceaccount, pdb
Delete             anything you are not configuring
Add                the thing your service actually needs — a ConfigMap,
                   a CronJob, a ServiceMonitor, a NetworkPolicy

Then check what you have with the two commands that cost nothing:

helm lint ./demo
helm template demo ./demo | kubectl apply --dry-run=server -f -

helm lint catches missing required fields, bad YAML and chart metadata errors. The second catches everything lint cannot — schema validation against your actual cluster, without changing anything.

Try it yourself: run helm template demo ./demo and trace three lines of the output back to their sources — one from values.yaml, one from _helpers.tpl, one from .Release. Being able to do that quickly is what makes the templating stage straightforward.

Common mistake: hardcoding resource names as metadata: { name: checkout } instead of name: {{ include "checkout.fullname" . }}. It works, once. The second install of the chart into the same namespace collides with the first, and the failure — “cannot re-use a name that is still in use”, or worse, two releases fighting over one object — is confusing precisely because the chart looked fine the first time.