Education › DevOps › Stage 3: Run at scale

Packaging with Helm

Charts, values, templating, and releasing the same app to many environments.

Intermediate ~30 min read Module 10 of 17

By the end of the Kubernetes module you had five YAML files for one service. Multiply that by three environments and twenty services, each differing only in image tag, replica count and hostname, and copy-paste becomes your biggest source of bugs. Helm is the package manager for Kubernetes: it turns those manifests into a parameterised, versioned chart that you install, upgrade and roll back as one unit.

After this module you can
  • Explain charts, values, releases and revisions, and how they relate
  • Read and write Helm templates using values, built-in objects, pipelines and named helpers
  • Deploy one chart to several environments with layered values files
  • Install, upgrade, inspect and roll back releases safely from a pipeline
  • Debug a chart with helm lint, helm template and dry runs before it touches a cluster

Charts, values and releases

A chart is a directory of templated Kubernetes manifests plus metadata. Values are the parameters you feed into those templates. A release is one installed instance of a chart in a cluster, with a name. Install the same chart twice with different names and values and you get two independent releases. Every upgrade or rollback of a release creates a new numbered revision, which Helm stores in the cluster as a Secret in the release's namespace.

text
orders/
  Chart.yaml            # name, chart version, appVersion, dependencies
  values.yaml           # default values: the chart's public interface
  templates/
    deployment.yaml
    service.yaml
    ingress.yaml
    _helpers.tpl        # named template snippets; files starting with _ render nothing
    NOTES.txt           # message printed after install
  charts/               # downloaded dependency charts
Chart.yaml
yaml
apiVersion: v2
name: orders
description: Orders API
type: application
version: 0.3.1          # version of the CHART (packaging); bump on any chart change
appVersion: "1.4.2"     # version of the APPLICATION it deploys; informational

Those two versions are independent, and mixing them up is a common confusion. Change a template and you bump version. Ship a new build of the app and appVersion, or more commonly the image tag in values, changes.

Using charts other people wrote

Much of your first Helm use is installing third-party software: an ingress controller, cert-manager, Prometheus. Charts are distributed through repositories or, increasingly, as OCI artifacts in the same registries that hold your images.

bash
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm search repo ingress-nginx --versions | head

helm show values ingress-nginx/ingress-nginx > defaults.yaml    # every knob the chart offers

helm upgrade --install ingress ingress-nginx/ingress-nginx \
  --namespace ingress --create-namespace \
  --version 4.11.3 \
  -f ingress-values.yaml

Three habits are worth forming at once. Use helm upgrade --install, which is idempotent: it installs if the release is missing and upgrades if it exists, so a pipeline can run the same command every time. Always pin --version, or you get whatever chart is newest on the day. And keep your overrides in a values file under version control instead of a growing list of --set flags.

Note

The chart version shown is an example. Look up the current one with helm search repo, and read the chart's changelog before a major-version upgrade.

Writing templates

Templates are Kubernetes YAML with Go template actions inside {{ }}. At render time Helm gives each template a set of built-in objects: .Values (merged values), .Release (name, namespace, revision), .Chart (the contents of Chart.yaml) and .Capabilities (what the cluster supports).

templates/deployment.yaml
helm
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "orders.fullname" . }}
  labels:
    {{- include "orders.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "orders.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "orders.selectorLabels" . | nindent 8 }}
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      containers:
        - name: orders
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          ports:
            - containerPort: {{ .Values.service.targetPort }}
          {{- with .Values.resources }}
          resources:
            {{- toYaml . | nindent 12 }}
          {{- end }}
  • Pipelines pass a value through functions, left to right: {{ .Values.image.tag | default .Chart.AppVersion | quote }}.
  • Whitespace control: {{- trims whitespace before the action and -}} trims after it. YAML is indentation-sensitive, so this matters.
  • toYaml plus nindent is how you drop a whole block from values (resources, tolerations, annotations) into the right indentation.
  • include renders a named template from _helpers.tpl and, unlike the built-in template action, its output can be piped onward.
  • with changes the scope and skips the block when the value is empty; if and range give conditionals and loops.
  • The checksum annotation solves the problem from the Kubernetes module: when the ConfigMap's content changes, the pod template changes too, so pods are rolled automatically.
templates/ingress.yaml: a conditional and a loop
helm
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "orders.fullname" . }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ . | quote }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ include "orders.fullname" $ }}
                port:
                  number: {{ $.Values.service.port }}
    {{- end }}
{{- end }}

Inside range, the dot becomes the current item. Use $ to reach the root scope, as the example does for $.Values. Forgetting that is the most common template error.

One chart, many environments

values.yaml holds sensible defaults and documents the chart's interface. Each environment gets a small file containing only what differs. Values are merged in order, and later sources win: chart defaults, then each -f file from left to right, then --set flags.

values.yaml (defaults)
yaml
replicaCount: 2
image:
  repository: ghcr.io/acme/orders-api
  tag: ""                 # empty means: use the chart's appVersion
service:
  port: 80
  targetPort: 8000
ingress:
  enabled: false
  className: nginx
  hosts: []
resources:
  requests:
    cpu: 100m
    memory: 128Mi
values-prod.yaml (overrides only)
yaml
replicaCount: 6
ingress:
  enabled: true
  hosts:
    - shop.example.com
resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    memory: 1Gi
bash
helm upgrade --install orders ./orders \
  --namespace shop-prod --create-namespace \
  -f values-prod.yaml \
  --set image.tag="$GIT_SHA" \
  --atomic --timeout 5m
INPUTS (LATER WINS)merged: later winshelm templateupgrade --installrecordshelm rollback NChart templatestemplates/*.yamlvalues.yamlchart defaults-f values-prodthen --setRenderGo templatesManifestsplain YAMLAPI serverapplyRevision NSecret in namespace
What `helm upgrade --install` does: values are merged in order of precedence, the templates are rendered into plain manifests, the manifests are applied, and the result is recorded as a numbered revision that a rollback can re-apply.

This is "build once, promote everywhere" for Kubernetes: the same chart and the same image move through environments, and only a values file and an image tag change. --atomic waits for the resources to become ready and rolls the release back automatically if they do not within the timeout, which is what you want in a pipeline. Maps merge key by key, but lists are replaced whole, so an environment file that sets hosts replaces the default list entirely.

Watch out

Do not put secrets in values files committed to Git, and remember that release values are stored in the cluster. Reference an existing Kubernetes Secret by name from the chart, and populate that Secret from a secret manager.

Operating releases

bash
helm list -A                                   # every release in every namespace
helm status orders -n shop-prod
helm history orders -n shop-prod               # revisions, with status and chart version
helm get values orders -n shop-prod            # the values this release was given
helm get manifest orders -n shop-prod          # the exact YAML Helm applied

helm rollback orders 7 -n shop-prod --wait     # back to revision 7 (creates a new revision)
helm uninstall orders -n shop-prod

A rollback re-applies the manifests of an earlier revision. It does not undo what happened outside Kubernetes: a database migration that ran during the upgrade stays applied. That is one reason schema changes should be backwards compatible, a theme that returns in the SRE track's module on safe releases.

Helm only knows about changes made through Helm. If someone runs kubectl edit on a resource that a release owns, the cluster drifts from what Helm recorded, and the next upgrade may overwrite the change or behave unexpectedly. Make every change through the chart. The GitOps module takes that discipline to its conclusion.

Test before it reaches a cluster

Template bugs are cheap to catch locally and expensive to catch in production. Build these commands into CI for every chart change.

bash
helm lint ./orders -f values-prod.yaml              # structural problems and bad practice
helm template orders ./orders -f values-prod.yaml   # render to stdout; no cluster needed
helm template orders ./orders -f values-prod.yaml | kubectl apply --dry-run=server -f -

helm upgrade --install orders ./orders -f values-prod.yaml --dry-run --debug

helm dependency update ./orders                     # fetch subcharts listed in Chart.yaml
helm package ./orders                               # orders-0.3.1.tgz
helm push orders-0.3.1.tgz oci://ghcr.io/acme/charts

helm template piped into a server-side dry run is the most valuable of these: the API server validates the rendered objects against the real schemas without creating anything. Render once per environment file, because a template can be valid with one set of values and broken with another. The helm-diff plugin adds helm diff upgrade, which shows exactly what an upgrade would change and is well worth adding to pull-request checks.

Tip

Helm is not the only option. Kustomize, built into kubectl, patches plain YAML without templates and suits simpler cases. Many teams use Helm for third-party software and either tool for their own services.

Hands-on practice

Turn your manifests into a chart

  1. Run helm create orders and read the generated _helpers.tpl, deployment.yaml and values.yaml to see the conventions. Then delete the templates you do not need.
  2. Move your Deployment, Service and ConfigMap from the Kubernetes module into templates/, replacing the image, replica count, ports and resources with values.
  3. Run helm lint and helm template, and pipe the rendered output into kubectl apply --dry-run=server -f - until it is clean.
  4. Create values-dev.yaml and values-prod.yaml containing only differences. Install both as separate releases in separate namespaces of your local cluster with helm upgrade --install.
  5. Add the ConfigMap checksum annotation. Change a config value, upgrade, and confirm the pods roll without any manual restart.
  6. Upgrade to an image tag that does not exist, using --atomic --timeout 2m. Watch Helm roll back by itself, then inspect helm history.
  7. Package the chart, push it to an OCI registry, and install it from there with a pinned --version.
Cheat sheet

Packaging with Helm — at a glance

Main things to focus on

  • Chart = templates + default values. Release = an installed chart with a name. Every change is a numbered revision.
  • version is the chart's own version; appVersion is the application's. They move independently.
  • helm upgrade --install is idempotent; add --atomic in pipelines; always pin --version for third-party charts.
  • Values precedence: chart defaults, then -f files left to right, then --set. Maps merge, lists are replaced.
  • Inside range and with, the dot changes. Use $ for the root scope.
  • toYaml ... | nindent N for blocks, {{- and -}} for whitespace.
  • Render and validate in CI: helm lint, helm template, server-side dry run.
  • Change things only through Helm, and keep secrets out of values files.

Install and upgrade

helm repo add NAME URL && helm repo updateRegister a chart repository and refresh its index
helm search repo KEYWORD --versionsFind charts and available versions
helm show values CHARTPrint a chart's default values
helm upgrade --install REL CHART -n NS --create-namespaceInstall or upgrade, idempotently
-f values-prod.yaml --set image.tag=abc123Override values; --set wins over files
--version X.Y.ZPin the chart version
--atomic --timeout 5mWait for readiness; roll back automatically on failure

Inspect and recover

helm list -AAll releases in all namespaces
helm status REL -n NSCurrent state of a release
helm history REL -n NSRevisions and their outcome
helm get values REL -n NSUser-supplied values (--all includes defaults)
helm get manifest REL -n NSThe rendered YAML that was applied
helm rollback REL REVISION -n NSRe-apply an earlier revision
helm uninstall REL -n NSRemove the release and its resources

Develop and test

helm create NAMEScaffold a chart with conventional helpers
helm lint CHART -f VALUESStatic checks
helm template REL CHART -f VALUESRender locally without a cluster
... | kubectl apply --dry-run=server -f -Validate rendered objects against the API server
helm upgrade --install ... --dry-run --debugSimulate against the cluster and print the output
helm dependency update CHARTDownload subcharts from Chart.yaml
helm package CHART && helm push FILE.tgz oci://REGISTRY/PATHPublish a chart as an OCI artifact

Template syntax

{{ .Values.key.sub }}Read a value
{{ .Release.Name }} / {{ .Release.Namespace }}Release metadata
{{ .Chart.Name }} / {{ .Chart.AppVersion }}Chart metadata
{{ .Values.x | default "y" | quote }}Pipeline with a default, then quoting
{{- toYaml .Values.resources | nindent 12 }}Insert a YAML block at the right indentation
{{- if .Values.x }} ... {{- else }} ... {{- end }}Conditional
{{- range .Values.list }} {{ . }} {{- end }}Loop; . is the item, $ is the root
{{ include "chart.name" . }}Render a named template; output can be piped
{{ required "image.tag is required" .Values.image.tag }}Fail rendering with a message if a value is missing

Common pitfalls

  • Using .Values inside a range block, where the dot has changed, instead of $.Values.
  • Wrong indentation from a missing nindent, which yields YAML that is valid but means something else.
  • Expecting an environment's list value to merge with the default list; it replaces it.
  • Installing third-party charts without --version, so that re-running the pipeline silently upgrades them.
  • Editing Helm-managed resources with kubectl edit, causing drift that the next upgrade overwrites.
  • Assuming helm rollback also reverses database migrations or other external side effects.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →