Containerization Packaging

Helm Fundamentals: Charts, Templates, Values, Releases & Repositories

Once you have written your third or fourth Kubernetes application, a pattern becomes painfully obvious: every app is almost the same pile of YAML — a Deployment, a Service, a ConfigMap, maybe an Ingress and a HorizontalPodAutoscaler — differing only in an image tag, a replica count, a hostname and a few environment variables. Copying that pile from app to app, and editing the same fields by hand for dev, staging and production, is tedious and error-prone. You forget to bump one value, you fat-finger an indent, and an environment drifts. Helm exists to end that misery. It is the package manager for Kubernetes — think apt, yum, npm or brew, but for clusters. It lets you bundle a set of Kubernetes manifests into a single, versioned, parameterised, shareable artefact called a chart, install that chart into a cluster as a named, tracked release, override its settings per environment without touching the templates, and upgrade or roll back the whole bundle as one atomic unit.

This lesson is deliberately exhaustive. We start from absolute zero — what Helm is and why it exists — and walk through every part of a chart, every major construct in the templating engine, the full rules of values precedence, the complete release lifecycle, repositories (classic and OCI), hooks, and the developer commands (lint, template, dependency) you will run a hundred times a day. By the end you will be able to read any chart on Artifact Hub, write your own from scratch, reason about exactly what Helm will send to the API server before it sends it, and upgrade and roll back releases with confidence. Everything is doable for free on your laptop with kind.

A quick orientation before we dive in: Helm 3 — the version everyone uses today — is a client-only tool. There is no server-side component (the old “Tiller” from Helm 2 is gone, which was a big security win). The helm CLI reads your chart, renders it into plain Kubernetes YAML locally, and then talks to the Kubernetes API server using your existing kubeconfig — exactly the same credentials and RBAC that kubectl uses. Helm can only do what you are allowed to do. It records what it installed by storing release metadata as Secrets inside the cluster. Hold onto those three facts; they explain almost everything about how Helm behaves.

Learning objectives

By the end of this lesson you can:

Prerequisites & where this fits

You should already be comfortable with the core Kubernetes objects — Pods, Deployments, Services, ConfigMaps and Secrets — and able to drive a cluster with kubectl apply. If “Deployment owns a ReplicaSet owns Pods” and “a Service is a stable address with a label selector” are familiar, you are ready; if not, do the Pods/Deployments/Services lesson first. You need Docker (or another container runtime) and the ability to run a local cluster — we use kind, but minikube or k3d work identically. This is the opening lesson of the Packaging module in the Kubernetes Zero-to-Hero course; it is the foundation that the more advanced Helm material — umbrella charts, library charts, hooks and rollback strategy and authoring production Helm charts — builds directly upon. A handy companion while you work is the Docker / kubectl / Helm command reference.

Core concepts

Before the mechanics, fix five mental models. They make every command and every file later feel inevitable rather than arbitrary.

A chart is a package; a release is an installation of it. This is the single most important distinction in all of Helm, and beginners trip over it constantly. A chart is the recipe — a directory (or a .tgz of one) containing templated manifests and default values. It is inert; it does nothing until you install it. A release is what you get when you install a chart into a cluster with a name: helm install web ./mychart creates a release called web. You can install the same chart many times under different release names (web-dev, web-staging, web-prod), each with its own values and its own independent lifecycle. Charts are to releases what a class is to an object, or what a Debian .deb is to the installed package on a running machine.

Helm renders templates into plain YAML, locally, then applies it. A Helm chart is not magic that the cluster understands — Kubernetes has never heard of Helm. The helm CLI takes your templates, substitutes your values, and produces ordinary Kubernetes manifests on your machine. Only then does it send that finished YAML to the API server. This means you can always see exactly what Helm will do before it does it, with helm template (render without touching the cluster) or helm install --dry-run --debug. There is no hidden server-side translation. When something goes wrong, render it and read the YAML — the bug is almost always visible there.

A release has revisions; upgrades and rollbacks move between them. Every time you install or upgrade a release, Helm stores a snapshot of the fully rendered manifests and the values used, tagged with an incrementing revision number. Revision 1 is the install; revision 2 is the first upgrade; and so on. Because Helm keeps these snapshots, helm rollback web 1 can put the cluster back exactly as revision 1 left it — it is just “apply the manifests we saved for revision 1, and record that as a new revision”. This history is what makes Helm safe: a bad upgrade is one command away from being undone.

Helm stores its state in the cluster, as Secrets. Helm 3 keeps the record of each release — which chart, which revision, which rendered manifests, what status — inside the cluster, by default as a Secret in the release’s namespace (one per revision), named like sh.helm.release.v1.<release>.v<revision>. That is why helm list only shows releases when you point kubectl at the right cluster and namespace: the truth lives in the cluster, not on your laptop. (You will occasionally see these Secrets when you kubectl get secret — do not delete them by hand or you orphan the release.)

Templating is just text substitution with a powerful engine. Helm’s templates are written in Go’s text/template language, augmented with the Sprig function library and a few Helm-specific functions. The engine does not understand Kubernetes; it produces text. That text happens to be YAML, so whitespace and indentation matter enormously — the most common Helm error by far is a template that renders structurally broken YAML. Once you internalise “I am generating text that must be valid YAML”, the dashes, the nindent, and the toYaml all make sense.

Key terms you will see throughout: chart (the package), release (a named install), revision (a versioned snapshot of a release), values (the parameters), template (a .yaml file with {{ }} directives), subchart / dependency (a chart bundled inside another), repository (a place charts are published), and hook (a resource that runs at a specific point in the release lifecycle).

Installing Helm and your first chart

Install the CLI (macOS shown; on Linux use the install script or your package manager, on Windows use winget/choco):

brew install helm                       # macOS
# curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash   # Linux
helm version                            # expect v3.14+ (any 3.x is fine)

Helm 3 needs no server-side installhelm version showing only a client version is correct and expected. The fastest way to see a chart is to let Helm scaffold one:

helm create demo

This generates a complete, working, best-practice chart in ./demo. We will dissect exactly what it produced in the next section — it is the perfect specimen because every part of the chart anatomy is present.

Chart anatomy: every file and directory

Run find demo -maxdepth 2 and you get the canonical chart layout. Here is the full structure, then every part explained:

demo/
├── Chart.yaml          # chart metadata (name, version, dependencies)
├── values.yaml         # default configuration values
├── values.schema.json  # (optional) JSON Schema to validate values
├── .helmignore         # files to exclude when packaging
├── Chart.lock          # (generated) pinned dependency versions
├── charts/             # subcharts / vendored dependencies live here
├── crds/               # (optional) CustomResourceDefinitions, installed first
└── templates/
    ├── _helpers.tpl    # named templates (partials), not rendered directly
    ├── NOTES.txt       # post-install message shown to the user
    ├── deployment.yaml
    ├── service.yaml
    ├── serviceaccount.yaml
    ├── ingress.yaml
    ├── hpa.yaml
    └── tests/
        └── test-connection.yaml   # `helm test` pods

Here is what every element does, with the gotcha that bites beginners:

File / dir What it is Required? Rendered as templates? Key fields / notes Gotcha
Chart.yaml Metadata: the chart’s identity and dependency list Yes No apiVersion, name, version, appVersion, type, dependencies, kubeVersion, description, icon, maintainers version (the chart version) and appVersion (the app version, e.g. the image tag) are different things — bumping one does not bump the other.
values.yaml Default configuration consumed via .Values No (but always present) No (it is the data, not a template) Any YAML structure you like Keys here are the defaults; everything a user might tune should appear here, documented, even if commented.
values.schema.json JSON Schema that validates the merged values No No Standard JSON Schema Validation runs on install/upgrade/lint/template; a typo’d value type fails fast — very useful, underused.
templates/ The manifests, as Go templates No (but the point of the chart) Yes Any .yaml/.tpl here is rendered, EXCEPT files starting with _ and NOTES.txt A plain .yaml with no {{ }} is still valid — it is rendered verbatim.
templates/_helpers.tpl Named template definitions (partials) No Defines, not emitted {{- define "name" -}}…{{- end -}} Files beginning with _ produce no output of their own; they are libraries you include.
templates/NOTES.txt Usage notes printed after install/upgrade No Yes (templated) Free text + {{ }} Rendered with the same context as templates; great for “how to reach your app” instructions.
charts/ Subcharts: dependencies vendored or pulled here No Each subchart renders itself Populated by helm dependency update or by dropping a chart dir in Anything in charts/ is a full chart and gets installed alongside the parent.
crds/ Raw CustomResourceDefinition YAML No No (installed as-is) Plain CRD manifests CRDs here are installed before templates and are never templated, upgraded, or deleted by Helm — a deliberate, often-surprising safety choice.
Chart.lock Pins resolved dependency versions + digest Generated No Mirrors dependencies with exact versions Commit it for reproducible builds (like package-lock.json).
.helmignore Glob patterns excluded when packaging No No .git, *.tmp, etc. Without it, you can accidentally ship secrets or junk inside the .tgz.

Chart.yaml in full

The metadata file deserves a closer look because its fields appear in templates (via .Chart) and govern dependencies. The most important fields:

Field Meaning Example Notes
apiVersion Chart format version v2 v2 = Helm 3 (dependencies live here); v1 is the legacy Helm 2 format. Always v2 today.
name The chart’s name demo Used in default resource names and the release.
version The chart version (SemVer) 0.1.0 Bump this on any change to the chart. This is what repositories index.
appVersion The version of the app the chart deploys "1.16.0" Free-form (quote it); typically your image tag. Exposed as .Chart.AppVersion.
type application or library application library charts only provide reusable templates and are not installable on their own.
kubeVersion SemVer range of supported Kubernetes versions ">=1.27.0-0" Helm refuses to install on a cluster outside this range.
dependencies List of subcharts see below Each has name, version, repository, optional condition, tags, alias, import-values.
description, icon, home, keywords, maintainers, sources Catalogue metadata Shown on Artifact Hub; good hygiene to fill in.
deprecated Marks the chart deprecated true Repos and Hub flag it.

A dependencies entry looks like this and is what makes one chart pull in another:

# Chart.yaml
apiVersion: v2
name: demo
version: 0.1.0
appVersion: "1.16.0"
dependencies:
  - name: redis
    version: "^19.0.0"          # SemVer range
    repository: "https://charts.bitnami.com/bitnami"
    condition: redis.enabled    # only installed if .Values.redis.enabled is true
    alias: cache                # mount it under .Values.cache instead of .Values.redis

You then run helm dependency update demo, which downloads redis into demo/charts/ and writes Chart.lock.

The templating engine

This is the heart of Helm. A template file in templates/ is plain text with {{ ... }} actions that the engine evaluates. Open demo/templates/service.yaml and you will see the shape immediately:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "demo.fullname" . }}
  labels:
    {{- include "demo.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: http
  selector:
    {{- include "demo.selectorLabels" . | nindent 4 }}

Everything between {{ and }} is code; everything else is emitted verbatim. Let us build up the full vocabulary.

Built-in objects

Helm hands every template a set of objects, all reachable from the root context . (a single dot). These are the data you template from:

Object What it holds Common members Example
.Values The merged values (defaults + overrides) whatever your values.yaml defines {{ .Values.replicaCount }}
.Release Facts about this install .Name, .Namespace, .Revision, .IsInstall, .IsUpgrade, .Service {{ .Release.Name }}
.Chart The contents of Chart.yaml .Name, .Version, .AppVersion, .Type, … {{ .Chart.AppVersion }}
.Capabilities What the target cluster supports .KubeVersion, .APIVersions.Has, .HelmVersion {{ if .Capabilities.APIVersions.Has "autoscaling/v2" }}
.Files Access to non-template files in the chart .Get, .GetBytes, .Glob, .AsConfig, .AsSecrets, .Lines {{ .Files.Get "config/app.conf" }}
.Template Info about the current template .Name, .BasePath {{ .Template.Name }}

.Release is worth memorising — .Release.Name, .Release.Namespace and .Release.Revision appear in almost every chart, typically to prefix resource names so multiple releases of the same chart never collide.

Functions and pipelines

A function transforms a value: {{ upper .Values.name }}. The real power is the pipeline — the | operator chains the output of one expression into the next as its last argument, exactly like a Unix pipe:

name: {{ .Values.name | lower | trunc 63 | trimSuffix "-" | quote }}

Read left to right: take name, lowercase it, truncate to 63 chars, strip a trailing dash, then wrap in quotes. Helm ships the entire Sprig library plus a few extras. The ones you will use constantly:

Function Purpose Example Result
default Fallback when empty `{{ .Values.tag default “latest” }}`
quote / squote Wrap in double / single quotes `{{ .Values.name quote }}`
upper / lower / title Case `{{ “Web” upper }}`
trunc / trimSuffix / trimPrefix Trim strings `{{ “abc-” trimSuffix “-” }}`
indent / nindent Add N spaces (nindent adds a leading newline first) `{{ toYaml .Values.x nindent 4 }}`
toYaml / toJson Serialise a structure `{{ toYaml .Values.resources nindent 2 }}`
required Fail render if value missing {{ required "image.repo is required!" .Values.image.repository }} error or value
printf Format strings {{ printf "%s-%s" .Release.Name .Chart.Name }} web-demo
b64enc / b64dec Base64 (for Secret data) `{{ “s3cr3t” b64enc }}`
eq / ne / lt / gt / and / or / not Logic / comparison {{ if and .Values.a .Values.b }} bool
hasKey / dig Map lookups safely {{ dig "tls" "enabled" false .Values.ingress }} nested value or default

The required function deserves special mention: it turns a missing value into a clear, fatal error at render time instead of a mysterious broken manifest — use it for values that have no sensible default (image repository, hostnames). And default is its gentler sibling for values that do have a fallback.

Named templates: define, include, template

Repeating the same block (labels, a name) across files is exactly the duplication Helm should remove. Named templates (often called partials or helpers) live in _helpers.tpl and are the answer. You define a snippet once and include it everywhere:

# templates/_helpers.tpl
{{- define "demo.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "demo.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{- end -}}

There are two ways to call a named template, and the difference matters:

Construct Behaviour Can be piped? Use when
{{ template "demo.labels" . }} Inserts the template’s output directly into the document Notemplate is an action, not a function, so you cannot pipe it through nindent Almost never in modern charts; legacy.
`{{ include “demo.labels” . nindent 4 }}` Returns the output as a string you can pipe Yes

The recurring idiom {{- include "demo.labels" . | nindent 4 }} means: render the demo.labels partial, then indent the whole block by 4 spaces (with a leading newline so it sits correctly under its parent key). Note the second argument — the . — passes the current context into the partial; forget it and the partial cannot see .Values or .Release and renders empty. That single missing dot is one of the most common Helm bugs.

tpl: rendering strings as templates

Sometimes a value itself contains template syntax — for example a user supplies an annotation value of "{{ .Release.Name }}-tls". Normally values are not re-evaluated, so that would emit literally. The tpl function renders an arbitrary string through the templating engine using the current context:

annotations:
  cert: {{ tpl .Values.certName . }}     # evaluates {{ }} found *inside* the value

This is the standard trick for letting users put template expressions in their values.yaml — and for chart authors to render a multi-line config blob that references release facts.

Control structures: if, with, range

Templates are not just substitution — they have flow control.

if / else if / else conditionally emit blocks. Helm treats empty values — false, 0, "", an empty list/map, and nil — as false:

{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
# ...
{{- else }}
# (no Ingress created)
{{- end }}

with narrows the scope — it rebinds . to a sub-object for the block, saving you long paths. Crucial gotcha: inside a with, the dot no longer points at the root, so .Release is unreachable unless you saved it ($ always means the root):

{{- with .Values.nodeSelector }}
nodeSelector:
  {{- toYaml . | nindent 2 }}     # here . is .Values.nodeSelector
{{- end }}

range iterates over a list or map. For a list, . is each element; for a map you get key and value:

env:
{{- range .Values.extraEnv }}
  - name: {{ .name }}
    value: {{ .value | quote }}
{{- end }}

# map form:
{{- range $key, $value := .Values.labels }}
  {{ $key }}: {{ $value | quote }}
{{- end }}

Again, inside range the dot is the current item — use $ to climb back to the root ($.Release.Name).

Whitespace control: the dashes

Because the engine emits text, stray newlines from the lines containing {{ }} would wreck your YAML. The - inside the braces trims whitespace: {{- trims everything (including the newline) before the action; -}} trims everything after. The reliable, readable convention — used by helm create and virtually every good chart — is to put {{- at the start of control-flow lines and pair structural output with nindent (which manages its own leading newline) rather than indent. When YAML comes out malformed, 90% of the time the fix is a dash you forgot or a nindent that should have been indent (or vice-versa). Always debug with helm template and read the raw output.

Values and overrides

Values are how a single chart serves dev, staging and production without edits. The chart ships sensible defaults in values.yaml; users override only what differs.

Supplying values

There are three ways to feed values, and they can be combined:

Method Syntax Use for Notes
Defaults values.yaml in the chart The baseline Always present; document every tunable.
A values file -f my.yaml (or --values) Per-environment config Repeatable — later files win; the idiomatic -f values.yaml -f values-prod.yaml.
Inline --set --set image.tag=1.2.3 One-off / CI overrides Highest precedence; supports --set-string, --set-file, --set-json.

--set has its own mini-syntax worth knowing: dots descend (a.b.c=x), commas separate (a=1,b=2), braces make lists (ports={80,443}), and you escape dots in keys with a backslash. Because it is fiddly and unreadable for anything non-trivial, prefer -f files for real configuration and reserve --set for the single value CI needs to inject (typically the image tag).

Precedence — who wins

When the same key is set in several places, Helm merges them with this order, lowest to highest (higher overrides lower):

  1. The subchart’s own values.yaml (lowest).
  2. The parent chart’s values.yaml.
  3. Each -f/--values file you pass, in order (later files beat earlier ones).
  4. --set / --set-string / --set-file / --set-json (highest — these always win).

So --set beats -f, and a later -f beats an earlier one, and any value you pass beats the chart’s defaults. Merging is deep for maps (keys combine) but replace for lists (a list in a higher source replaces the lower list entirely — it does not append). That list behaviour surprises everyone once: setting extraEnv in --set does not add to the chart’s default extraEnv; it overwrites it.

Subchart and global values

When a chart has dependencies, the parent can configure them. Values for a subchart live under a key matching the subchart’s name (or its alias):

# parent values.yaml
redis:                 # configures the `redis` subchart
  auth:
    enabled: false
  replica:
    replicaCount: 2

Sometimes several charts need the same value — an image registry, a domain, a storageClass. Repeating it under every subchart is brittle. The reserved global key solves this: anything under .Values.global is visible to the parent and every subchart as .Values.global:

# parent values.yaml
global:
  imageRegistry: myregistry.example.com
  storageClass: fast-ssd

Now both the parent and the redis subchart can read {{ .Values.global.imageRegistry }}. Globals are the clean way to share cross-cutting settings; use them sparingly and document them, because they create coupling between charts.

Inspecting the effective values

To see exactly what a release is running with — defaults plus every override merged — use:

helm get values web              # only the user-supplied overrides
helm get values web --all        # the FULL computed values, defaults included
helm show values ./demo          # the chart's default values.yaml (before any install)

helm get values <release> --all is the definitive answer to “what is this release actually configured with?” — invaluable when debugging an environment that drifted.

Releases and revisions: the lifecycle

Now the commands that put charts into clusters and manage them over time. Every one of these operates on a release (a named install), and most produce a new revision.

Install

helm install web ./demo \
  -n web --create-namespace \
  -f values-prod.yaml \
  --set image.tag=1.4.2 \
  --wait --timeout 5m

This creates release web (revision 1) in namespace web. The flags you will reach for most:

Flag What it does Why it matters
-n, --namespace Target namespace Releases are namespaced; the same name in two namespaces are two releases.
--create-namespace Create the namespace if absent Saves a separate kubectl create ns.
--generate-name / --name-template Auto-name the release For ephemeral installs.
--wait Block until resources are Ready Otherwise install returns as soon as the API accepts the objects, before Pods are up.
--wait-for-jobs Also wait for Jobs to complete Pairs with --wait when the release includes Jobs.
--timeout How long --wait/hooks may take Default 5m; raise for slow images/migrations.
--atomic If install fails, automatically uninstall the partial release All-or-nothing; implies --wait.
--dry-run Render + (optionally) server-validate without installing --dry-run=server checks against the live API; =client is offline.
--debug Print the rendered manifests and extra detail Pair with --dry-run to preview.
--values, -f / --set Supply overrides As covered above.
--description A human note stored with the revision Shows in helm history.

Idempotent install/upgrade is the CI-friendly pattern — helm upgrade --install web ./demo … installs the release if it does not exist and upgrades it if it does, so your pipeline runs the same command every time.

Upgrade

helm upgrade web ./demo -n web -f values-prod.yaml --set image.tag=1.5.0 --atomic --wait

This computes the difference between the new rendered manifests and revision 1, applies the changes, and records revision 2. Helm performs a three-way strategic merge: it compares the old chart’s manifests, the new chart’s manifests, and the live objects in the cluster, so changes made out-of-band are reconciled sensibly rather than blindly stomped. Key upgrade flags beyond the install set:

Flag What it does Gotcha
--atomic Roll back automatically to the previous revision if the upgrade fails The safest way to upgrade in production.
--install Install if the release does not yet exist Enables the idempotent one-command pattern.
--reset-values Ignore the previous release’s values; use chart defaults + this command’s overrides Use when you want a clean slate.
--reuse-values Reuse the last release’s values and merge this command’s --set on top Handy for tweaking one value, but can mask drift; be deliberate.
--force Delete and recreate resources that cannot be updated in place Causes downtime; last resort for “field is immutable” errors.
--cleanup-on-fail Delete new resources created during a failed upgrade Avoids orphaned leftovers.

A subtle but important default governs which values an upgrade uses. By default, helm upgrade does not carry over the previous release’s overrides — it computes the new values from the chart’s values.yaml plus whatever -f/--set you supply on this command. If you want the last release’s values reused, you must opt in with --reuse-values (merge this command’s --set on top of the previous values) or --reset-values (explicitly ignore them and use chart defaults + this command’s overrides). The safe habit that sidesteps the whole question: always pass the same -f files on every upgrade, so the result is deterministic and never depends on what the last upgrade happened to set.

Rollback

helm history web -n web          # see all revisions and their status
helm rollback web 1 -n web --wait   # restore the cluster to revision 1

Rollback re-applies the saved manifests of the target revision and records the result as a new revision (rolling back from revision 3 to revision 1 creates revision 4 that is revision 1’s content). This is why history never shrinks and why rollback is itself reversible. helm rollback web with no revision number rolls back to the immediately previous revision.

Uninstall, history, status

Command What it does Notes
helm uninstall web -n web Delete the release and all its resources Add --keep-history to retain the revision records (so you could see what was there); without it, the release is gone entirely.
helm list -n web List releases in a namespace -A/--all-namespaces for the whole cluster; --all includes failed/uninstalling.
helm history web -n web Show every revision: number, status, chart, app version, description The audit trail; --max limits rows.
helm status web -n web Current status, last deployment time, and the rendered NOTES.txt Add --show-resources to list the live objects.
helm get manifest web -n web The exact YAML currently applied for the release The ground truth of what Helm put in the cluster.
helm get all web -n web Everything: values, manifest, hooks, notes The kitchen-sink debug command.

How many revisions Helm keeps is controlled by --history-max (default 10) on install/upgrade; older revision Secrets are pruned beyond that.

Repositories and OCI registries

Charts are shared through repositories. There are two kinds today.

Classic HTTP repositories

A classic repo is just a web server hosting chart .tgz files plus an index.yaml catalogue. You add it, refresh the index, search, and install:

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update                                  # refresh all repos' indexes
helm repo list                                    # what's configured
helm search repo nginx                            # search added repos
helm search hub wordpress                         # search Artifact Hub (the public catalogue)
helm install my-redis bitnami/redis --version 19.0.1

helm search repo searches your added repositories (offline, against the cached indexes); helm search hub searches Artifact Hub, the public registry of thousands of published charts. Always pin --version for reproducibility — without it you silently get the newest, which can change under you.

OCI registries

Modern Helm treats container registries (anything OCI-compliant — GHCR, ECR, ACR, GAR, Docker Hub, Harbour) as first-class chart stores, so a chart lives right next to the images it deploys. There is no repo add; you reference charts by an oci:// URL:

helm registry login ghcr.io -u USERNAME            # authenticate (uses your registry creds)
helm package demo                                  # produces demo-0.1.0.tgz
helm push demo-0.1.0.tgz oci://ghcr.io/myorg/charts
helm pull oci://ghcr.io/myorg/charts/demo --version 0.1.0
helm install web oci://ghcr.io/myorg/charts/demo --version 0.1.0

OCI is the recommended direction for private charts — one registry, one auth model, one set of access controls for both images and charts. Note there is no central index to search; you address charts by their full path and version.

Hooks

Sometimes you need an action to happen at a precise point in the release lifecycle — run a database migration before the new app starts, seed data after install, or clean up before uninstall. Hooks are ordinary Kubernetes resources (usually Jobs) annotated with helm.sh/hook so Helm runs them at the right moment instead of treating them as part of the normal release:

apiVersion: batch/v1
kind: Job
metadata:
  name: "{{ .Release.Name }}-db-migrate"
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "-5"               # lower weight runs first
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: myorg/migrator:1.5.0
          command: ["./migrate.sh"]

The hook points, in lifecycle order:

Hook Fires Typical use
pre-install After templates render, before any resource is created Pre-flight checks, create namespaces/secrets a resource needs.
post-install After all resources are created Seed data, register the release with an external system.
pre-upgrade Before upgrade applies Database migrations — the classic use.
post-upgrade After upgrade applies Cache warm-up, smoke checks.
pre-rollback / post-rollback Around a rollback Reverse migrations, notify.
pre-delete / post-delete Around uninstall Drain connections, deregister, clean external state.
test On helm test Validate a release is working (the templates/tests/ Pods).

Two annotations govern hooks: hook-weight orders multiple hooks at the same point (lower runs first, ascending), and hook-delete-policy controls cleanup — before-hook-creation (delete the previous run before re-running, the sane default), hook-succeeded (delete on success), or hook-failed (delete on failure). A subtle but important fact: hook resources are not tracked as part of the release — Helm does not delete them on uninstall unless their delete-policy says so, and they do not appear in helm get manifest. They are fire-and-forget by design.

Developer commands: lint, template, dependency

Three commands you run constantly while authoring, none of which touch the cluster (except dependency reaching out to fetch charts):

Command What it does When
helm lint ./demo Static checks: valid Chart.yaml, parseable templates, schema validation, common-mistake warnings Before every commit; in CI.
helm template web ./demo -f values-prod.yaml Renders all templates to stdout without a cluster To see the YAML, diff between value sets, or feed kubectl apply -f - in GitOps.
helm template … --show-only templates/deployment.yaml Render just one file Focus on the manifest you are editing.
helm dependency update ./demo Resolve Chart.yaml dependencies, download into charts/, write Chart.lock After editing dependencies.
helm dependency build ./demo Rebuild charts/ from an existing Chart.lock (exact pinned versions) Reproducible CI builds.
helm dependency list ./demo Show dependencies and whether they are present Quick status.
helm test web -n web Run the test hooks against a live release After install, to prove it works.

The workflow muscle memory is: edit → helm linthelm template | less to eyeball the YAML → helm upgrade --install --dry-run=server to validate against the cluster → real install. Linting and rendering catch the overwhelming majority of mistakes before they ever reach the API server.

Helm chart structure & release flow

The diagram shows the full picture: a chart on the left (Chart.yaml, values.yaml, templates/, charts/, _helpers.tpl, NOTES.txt, crds/) feeding the templating engine — where .Values, .Release and .Chart are merged in and rendered to plain Kubernetes YAML — which Helm applies to the cluster as a named release, storing each revision as a Secret so it can upgrade and roll back.

Hands-on lab

You will scaffold a chart, render it, install it onto a free local cluster, override a value, upgrade it, roll it back, add a dependency, and clean up. Everything runs on your laptop at zero cost.

1. Create a cluster and a chart

kind create cluster --name helm-lab           # free local Kubernetes
helm create webapp                            # scaffold a working chart

Expected: kubectl get nodes shows one Ready node, and webapp/ contains the full chart layout from earlier.

2. Render before you install (see the YAML)

helm template demo ./webapp | head -40
helm template demo ./webapp --set replicaCount=3 --show-only templates/deployment.yaml | grep replicas

Expected: the first prints rendered manifests; the second shows replicas: 3. You just proved templating works without a cluster.

3. Lint, then install with a wait

helm lint ./webapp
helm install web ./webapp -n demo --create-namespace --wait --timeout 2m

Expected: 1 chart(s) linted, 0 chart(s) failed, then STATUS: deployed, and the rendered NOTES.txt printed. Verify:

helm list -n demo
kubectl get deploy,svc,pods -n demo
helm get values web -n demo --all | head

You should see the web release, a Deployment with 1 replica, a Service, and the fully computed values.

4. Override and upgrade (create revision 2)

helm upgrade web ./webapp -n demo --set replicaCount=3 --atomic --wait
helm history web -n demo
kubectl get pods -n demo

Expected: history shows revision 1 (deployed→superseded) and revision 2 (deployed), and three Pods are now running. Note the image and app version columns.

5. Roll back (create revision 3 = revision 1)

helm rollback web 1 -n demo --wait
kubectl get pods -n demo
helm history web -n demo

Expected: back to a single Pod; history now shows three revisions, with revision 3 marked deployed.

6. Add a dependency

cat >> webapp/Chart.yaml <<'EOF'
dependencies:
  - name: redis
    version: "^19.0.0"
    repository: "https://charts.bitnami.com/bitnami"
    condition: redis.enabled
EOF
helm repo add bitnami https://charts.bitnami.com/bitnami
helm dependency update ./webapp
ls webapp/charts/                # redis-*.tgz now vendored; Chart.lock written

Expected: a redis-*.tgz appears under charts/ and Chart.lock is created. (You need not install it — condition: redis.enabled defaults off — but you have proven dependency resolution.)

7. Validation

helm status web -n demo --show-resources
helm get manifest web -n demo | head -20
kubectl get secret -n demo | grep sh.helm.release    # the per-revision state Secrets

Seeing the sh.helm.release.v1.web.v* Secrets confirms Helm stored each revision’s state in the cluster, exactly as the architecture promised.

Cleanup

helm uninstall web -n demo
kubectl delete namespace demo
kind delete cluster --name helm-lab

Cost note

Everything here is free: kind runs Kubernetes in Docker on your machine, Helm is open source, and the Bitnami chart is pulled from a public repository. The only “cost” is local CPU/RAM and a few hundred MB of downloads. Nothing touches a paid cloud.

Common mistakes & troubleshooting

Symptom Likely cause Fix
error converting YAML to JSON / mapping values are not allowed on install A template rendered structurally broken YAML — almost always bad indentation or a missing whitespace dash helm template ./chart | less, find the malformed block; fix with nindent/indent and {{-/-}}.
A partial (include) renders empty You forgot the context argument: {{ include "x.labels" }} instead of {{ include "x.labels" . }} Add the trailing . so the partial can see .Values/.Release.
nil pointer evaluating interface {} Walking into a value that does not exist (.Values.foo.bar where foo is unset) Guard with if, with, or default/dig; e.g. `{{ .Values.foo.bar
--set extraEnv=... wiped the chart’s defaults List values replace, they do not merge Provide the complete list, or model it as a map you can deep-merge.
cannot re-use a name that is still in use A release by that name already exists (or a failed one lingers) helm list -A to find it; helm uninstall, or use helm upgrade --install.
Upgrade succeeded but app is broken; rollback needed A bad config or image reached the cluster helm rollback <rel> <good-rev>; in future use --atomic so failures self-revert.
another operation (install/upgrade) is in progress A prior command crashed leaving the release in pending-* helm rollback to the last good revision, or as a last resort delete the stuck pending release Secret.
CRD changes in crds/ not applied on upgrade Helm installs CRDs once and never upgrades/deletes them Update CRDs out of band (kubectl apply), or manage them via templates with care.
--dry-run passes but real install fails on admission --dry-run=client does not contact the API; admission webhooks/quotas only check server-side Use --dry-run=server to validate against the live cluster.

Best practices

Security notes

Interview & exam questions

  1. What is the difference between a chart and a release? A chart is the package — a versioned, templated bundle of Kubernetes manifests plus default values; it is inert. A release is a named installation of a chart into a cluster (helm install web ./chart → release web). The same chart can be installed many times under different release names, each with its own values and lifecycle.

  2. How does Helm 3 differ architecturally from Helm 2? Helm 3 removed Tiller, the in-cluster server component. Helm 3 is client-only: it renders locally and talks to the API server using your kubeconfig/RBAC. This eliminated Tiller’s cluster-admin attack surface and made Helm respect normal Kubernetes authorisation. Release state moved to Secrets in the release namespace.

  3. Where does Helm store release state, and why does it matter? In the cluster, as Secrets named sh.helm.release.v1.<release>.v<revision> (one per revision) in the release’s namespace. It matters because the source of truth is the cluster, not your laptop — helm list reflects the cluster you are pointed at — and deleting those Secrets by hand orphans the release.

  4. Explain values precedence. Lowest to highest: subchart values.yaml → parent values.yaml → each -f file in order (later wins) → --set/--set-string/etc. (highest). Maps deep-merge; lists are replaced wholesale by a higher-precedence source rather than appended.

  5. What is the difference between template and include? Both invoke a named template. template is an action that inserts output directly and cannot be piped (so you cannot nindent it). include is a function that returns a string, so you can pipe it: {{ include "x.labels" . | nindent 4 }}. Prefer include.

  6. What does --atomic do? It makes an install/upgrade all-or-nothing: it implies --wait, and if the operation fails (a resource never becomes ready within the timeout, or a hook fails), Helm automatically rolls back to the previous revision (or uninstalls, on a failed first install). It is the safest way to deploy.

  7. How do helm rollback and revisions work? Helm snapshots the fully rendered manifests of every install/upgrade as a numbered revision. helm rollback <rel> <n> re-applies revision n’s manifests and records the result as a new revision (so rollback is itself reversible and history never shrinks). With no number it rolls back to the immediately previous revision.

  8. What is special about the crds/ directory? CRDs placed in crds/ are installed before templates, are never templated, and are never upgraded or deleted by Helm. This is a deliberate safety measure (deleting a CRD would delete all its custom resources cluster-wide). To change a CRD you update it out of band.

  9. What is the global value for? .Values.global is a reserved key whose contents are visible to the parent chart and all subcharts. It is the clean way to share cross-cutting settings (image registry, storage class, domain) across an umbrella chart without repeating them under each subchart.

  10. When and why would you use the tpl function? When a value itself contains template syntax that you want evaluated (e.g. a user supplies an annotation value of "{{ .Release.Name }}-tls"). Values are not normally re-rendered; tpl <string> . runs an arbitrary string through the engine with the current context.

  11. What are Helm hooks and name a classic use. Hooks are normal Kubernetes resources annotated with helm.sh/hook to run at a specific lifecycle point (pre/post-install/upgrade/rollback/delete, and test). The classic use is a pre-upgrade Job that runs database migrations before the new application version starts. Ordering is by hook-weight; cleanup by hook-delete-policy.

  12. How do you preview what Helm will apply without touching the cluster? helm template <name> ./chart -f values.yaml renders to stdout offline; helm install/upgrade --dry-run=server --debug additionally validates against the live API (admission, quotas). Use these before every real change.

Quick check

  1. You run helm install web ./chart twice in the same namespace. What happens the second time, and how do you make the command safe to re-run?
  2. The same key is set in values.yaml, in -f prod.yaml, and via --set. Which value wins?
  3. A named template you include is rendering nothing. What is the most likely single-character mistake?
  4. You add a field to a CRD in crds/ and run helm upgrade. The change does not appear. Why?
  5. After a failed upgrade your app is broken. What one command restores the last good state, and what flag would have prevented the problem?

Answers

  1. The second install fails with “cannot re-use a name that is still in use” — release names are unique per namespace. Make it safe with helm upgrade --install web ./chart, which installs if absent and upgrades if present.
  2. --set wins. Precedence is subchart defaults < parent defaults < -f files (later beats earlier) < --set.
  3. A missing context dot{{ include "x" }} instead of {{ include "x" . }} — so the partial cannot see .Values/.Release and renders empty.
  4. Helm never upgrades or deletes CRDs placed in crds/; they are installed once and then left alone. Update the CRD out of band with kubectl apply.
  5. helm rollback web <last-good-revision> (or just helm rollback web for the previous one). Deploying with --atomic would have auto-rolled-back the failed upgrade in the first place.

Exercise

Take a small app you have written (or invent one: a web Deployment + Service + ConfigMap). Convert its raw manifests into a Helm chart by hand (do not just helm create and walk away — author the templates). Requirements: (a) parameterise the image repository and tag, replica count, service type/port, and resource requests/limits via values.yaml; (b) add a _helpers.tpl with a fullname and a labels partial and use include … | nindent in every manifest; © make the ConfigMap optional behind .Values.config.enabled with an if; (d) add a second values file values-prod.yaml that overrides replicas and resources; (e) write a values.schema.json that requires image.repository and types replicaCount as an integer; (f) add a pre-upgrade hook Job (it can just echo "migrating"); and (g) prove the whole thing with helm lint, helm template -f values-prod.yaml, then install, upgrade (change the tag), and helm rollback on kind. Bonus: add the Bitnami redis chart as a condition-gated dependency and configure it via subchart values, then run helm dependency update and confirm Chart.lock.

Certification mapping

CKAD (Certified Kubernetes Application Developer):

While Helm is most prominent in CKAD, the same skills support day-to-day work for CKA (operating clusters where workloads arrive as charts) and appear constantly in real platform/SRE interviews.

Glossary

Next steps

HelmKubernetesChartsTemplatingPackagingCKAD
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading