home / writing / helm-charts-explained

Helm Charts Explained

Helm is the package manager for Kubernetes: it bundles manifests into versioned charts and tracks each install as a release. This guide covers templating, dependencies, hooks, testing and automation.

Core concepts

Raw Kubernetes manifests are static: running the same application in dev, staging and production usually means copying YAML and editing it. Helm solves this with three ideas.

  • Chart: a package of templated manifests plus metadata and default configuration.
  • Release: one installed instance of a chart in a cluster, with its own name and revision history.
  • Repository: an HTTP or OCI location that stores packaged charts.

Since Helm 3, Helm is client-only. It renders templates locally, sends the result to the Kubernetes API, and by default stores release state as Secrets in the release namespace, so there is no server component to run.

Chart structure

mychart/
  Chart.yaml          # name, version, appVersion, dependencies
  values.yaml         # default configuration
  values.schema.json  # optional validation of values
  charts/             # packaged dependencies
  templates/
    deployment.yaml
    service.yaml
    _helpers.tpl      # named templates, not rendered directly
    NOTES.txt         # message printed after install
    tests/            # test hooks

Chart.yaml requires apiVersion, name and version. The chart version follows semantic versioning and changes whenever the chart changes; appVersion describes the application it deploys and is informational. Scaffold a new chart with helm create mychart.

Templates and values

Templates use Go templating. Values come from values.yaml, overridden by -f other.yaml files and finally by --set key=value, with later sources winning. Inside a template you read them through built-in objects: .Values, .Release (name, namespace, revision), .Chart and .Capabilities.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "mychart.fullname" . }}
spec:
  replicas: {{ .Values.replicaCount }}
  # selector and pod labels omitted for brevity
  template:
    spec:
      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          {{- with .Values.resources }}
          resources:
            {{- toYaml . | nindent 12 }}
          {{- end }}
          {{- if .Values.env }}
          env:
            {{- range $name, $val := .Values.env }}
            - name: {{ $name }}
              value: {{ $val | quote }}
            {{- end }}
          {{- end }}

Flow control, pipelines and reuse

  • if/else, with and range control what gets rendered; the {{- form trims surrounding whitespace, which matters because YAML is indentation sensitive.
  • Pipelines chain functions: quote, default, upper, toYaml, indent/nindent, required.
  • Named templates defined with define in _helpers.tpl are invoked with include, which (unlike template) can be piped. Use them for shared labels and names.
  • A library chart contains only named templates and is reused by other charts.

Use values.schema.json to enforce types and required keys, and required or fail in templates to give clear error messages on bad input.

Dependencies

A chart can depend on other charts, such as a database. Declare them in Chart.yaml:

dependencies:
  - name: postgresql
    version: "15.x.x"
    repository: https://charts.example.com
    condition: postgresql.enabled

Run helm dependency update to download them into charts/ and write a lock file; helm dependency build reproduces exactly what the lock file pins. A parent overrides a child's values by nesting them under the child's name in its own values file, so postgresql.auth.database reaches the subchart as auth.database. The condition and tags fields toggle subcharts, and import-values can pull selected child values up into the parent.

Hooks

Hooks run Kubernetes resources, typically Jobs, at specific points in the release lifecycle. You mark a template with an annotation:

metadata:
  annotations:
    "helm.sh/hook": pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": hook-succeeded

Available events include pre-install, post-install, pre-upgrade, post-upgrade, pre-delete, post-delete, pre-rollback, post-rollback and test. Weights order hooks of the same type, and the delete policy cleans up finished Jobs. Typical uses are database migrations before an upgrade and smoke checks afterwards.

Hook resources are not part of the tracked release manifest, so Helm will not remove them on uninstall unless a delete policy says so. A failing pre-upgrade hook blocks the upgrade, which is intentional but can surprise you.

Release lifecycle

helm repo add example https://charts.example.com
helm repo update
helm search repo wordpress
helm show values example/wordpress > my-values.yaml
helm install blog example/wordpress -n web --create-namespace -f my-values.yaml
helm list -n web
helm get values blog -n web
helm upgrade blog example/wordpress -n web -f my-values.yaml
helm history blog -n web
helm rollback blog 1 -n web
helm uninstall blog -n web

Every install, upgrade and rollback creates a new revision, which is why rollback is cheap and safe. Useful flags: --atomic rolls back automatically if an upgrade fails, --wait blocks until resources are ready, and upgrade --install makes a command idempotent for pipelines. Use --reuse-values carefully; keeping values in version-controlled files is more predictable.

Testing and validation

Check a chart in layers, from cheap to thorough:

  1. helm lint mychart catches structural and template mistakes.
  2. helm template mychart -f values-prod.yaml renders manifests locally so you can read or diff them.
  3. helm install --dry-run=server --debug or helm upgrade --dry-run=server validates against the live API server without creating anything.
  4. Schema validators such as kubeconform can check rendered output against Kubernetes schemas.
  5. helm test <release> runs resources annotated with helm.sh/hook: test, usually a pod that curls the service and exits non-zero on failure.

Packaging and automation

helm package mychart produces a versioned .tgz. Publish it to a chart repository (a static index.yaml plus archives, generated with helm repo index) or push it to an OCI registry with helm push. Helm can sign packages for provenance.

A typical CI pipeline for a chart repository:

  1. Lint and render the chart on every pull request, against several values files.
  2. Install it into a throwaway cluster (for example kind) and run helm test.
  3. On merge or tag, bump the chart version, package and publish.
  4. Deploy with helm upgrade --install plus automatic rollback on failure or hand the chart to a GitOps controller.

Best practices: bump version on every chart change, keep defaults safe and minimal, document every value, avoid hard-coding namespaces, and never put real secrets in values.yaml; reference an existing Secret or use an external secrets tool instead.