Skip to content
Back to student guides
HelmDevOpsContainers & Kubernetes3 levels135 sectionsCovers Helm 4.3

The Complete Helm Guide

Package and release Kubernetes applications with Helm charts. Taught at three levels — Beginner, Mid-level and Senior — each with an in-depth guide, interview prep, and practical tips.

Official docs AI-drafted · community review in progressHelp review it
25sections
80examples

This is part one of three. It covers everything you need to do real work with Helm, not a teaser. By the end you can install a packaged application into a Kubernetes cluster with one command, change its configuration without editing YAML by hand, upgrade it, roll it back when the upgrade goes wrong, and remove it cleanly. You can also build your own chart from nothing, preview what it will do before it touches a cluster, test it, package it, and push it to a registry. Mid-level and Senior take the same topics further; nothing here is thrown away.

The guide targets Helm 4.3, the current release at the time of writing. Helm 4 shipped in November 2025, the first new major version in six years. If you have seen older tutorials, most of what they teach still works, and this guide points out the few places where the new version behaves differently.

Each section ends with a Try it task. Do them as you go. They take a few minutes each, and Helm only makes sense once you have watched a release install, break, and recover on a cluster of your own. You need a terminal, a little comfort with YAML, and a rough idea of what a Kubernetes Deployment and Service are. If those two words are new, read the Kubernetes guide's beginner part first at /student-guides/kubernetes, then come back.

What Helm is, and the problem it solves

Helm is the package manager for Kubernetes. It takes a bundle of Kubernetes configuration files, fills in the values you choose, sends the result to your cluster, and remembers exactly what it sent so it can upgrade or undo it later.

CHARTtemplates + defaults
→
VALUESyour choices
→
RELEASEinstalled in a cluster
→
REVISIONShistory you can roll back

The diagram is the whole idea, and the rest of the guide fills it in. To see why it exists, start with what deploying to Kubernetes looks like without it.

A real application is never one object. A small web service needs a Deployment to run the containers, a Service to give them a stable address, a ConfigMap for settings, probably an Ingress for outside traffic, a ServiceAccount, maybe a HorizontalPodAutoscaler. That is five to ten YAML files, and every one of them contains values that change between environments: the image tag, the number of replicas, the hostname, the memory limit. On your laptop you want one replica and a tiny memory limit. In production you want three replicas and a real limit.

Without a package manager, teams handled this in three painful ways. They copied the folder of YAML per environment and edited each copy, so the copies drifted apart and nobody knew which differences were deliberate. They ran sed or envsubst over the files before kubectl apply, which works until a value contains a slash or a quote. Or they kept a wiki page of steps that slowly went out of date. None of these answered the questions that matter during an incident: what exactly did we deploy last Tuesday, what did it replace, and how do we get back?

Helm solves that with three ideas, each of which gets its own section below.

Packaging. The files are bundled into a chart, a folder (or a single versioned archive) with a fixed layout. The chart is the unit you version, share, and install. Somebody else's chart for PostgreSQL, Prometheus, or an ingress controller installs with the same command as your own.

Templating. Inside the chart, the parts that change are replaced with placeholders, and a file of values fills them in. One chart serves every environment; only the values differ.

Release tracking. Every install is recorded as a release with numbered revisions. helm history shows the past, helm rollback returns to it, and helm uninstall removes everything the release created and nothing it didn't.

📦

Install third-party software

Databases, monitoring stacks, ingress controllers and most cloud-native tools publish a chart. One command instead of a forty-page manifest.

🎛️

One app, many environments

The same chart runs in dev, staging and production. A small values file per environment holds the differences.

⏪

Safe upgrades

Every change is a numbered revision. A bad release is undone with one command instead of a scramble through git history.

🧰

Shareable packaging

Your team's service becomes a versioned artifact in a registry that anyone, including a CI pipeline, can install.

It is worth being honest about what Helm is not. It is not a deployment platform that watches your cluster. Helm acts only when you run it. If someone edits a live object by hand afterwards, Helm does not notice until the next upgrade. Continuous reconciliation is the job of GitOps tools such as Argo CD and Flux, which the Senior part of this series discusses. And it is not a container builder; building images is Docker's job, covered in /student-guides/docker. Helm picks up after that: the image exists in a registry, and now you need to run it on Kubernetes repeatably.

Try it
  1. Pick an application you know. List every Kubernetes object it would need (Deployment, Service, and so on).
  2. For each object, underline the fields that would differ between a laptop and production.
  3. Count the underlined fields.
a short list of values, such as image tag, replica count, hostname and memory, repeated across several files. Those repeated, changing values are exactly what a chart turns into placeholders.

Helm 3, Helm 4, and which one you are learning

You will meet both version numbers in job descriptions, blog posts and Stack Overflow answers, so get the picture straight once.

Helm 2 (2016 to 2019) had a server-side component called Tiller that ran inside the cluster with broad permissions. It was a security problem and was removed. Helm 3 (November 2019) made Helm a pure command-line client. Helm 4 reached general availability on 12 November 2025, announced at KubeCon North America in Atlanta. The 4.x line releases a minor version roughly every four months and a patch about monthly; this guide was checked against 4.3.0, released on 9 September 2026.

Helm 3 has been closed to new features. Its last minor release, 3.22.0, arrived a day after 4.3.0, and Helm 3 receives security fixes only until 10 February 2027. After that date it gets no patches at all. So if you are learning Helm today, learn Helm 4. Employers will still have Helm 3 in places, and the commands you learn transfer almost entirely.

Unchanged in Helm 4

  • Charts with apiVersion: v2 keep working as they are
  • Existing Helm 3 releases are managed in place, with no migration step
  • Release records still live in the cluster as sh.helm.release.v1 Secrets
  • The core commands: install, upgrade, rollback, uninstall, template, lint

Different in Helm 4

  • --atomic is now --rollback-on-failure
  • --force is now --force-replace
  • --wait takes a strategy (watcher, hookOnly, legacy)
  • New releases use server-side apply by default
  • Post-renderers are plugins; helm registry login accepts a host only

The old flag names still work in Helm 4; they print a deprecation warning and then do what you asked. That is why a Helm 3 tutorial rarely breaks outright. This guide always uses the current names, and a callout flags each place where older material differs. The mid-level guide covers the new wait strategies and server-side apply in depth; as a beginner you only need to know they exist.

Which Helm do you have? Run helm version --short. If it starts with v4, you match this guide. If it starts with v3, every command here still works except where a callout says otherwise, but plan to upgrade before February 2027.
Try it
  1. If Helm is already installed, run helm version --short and note the major version.
  2. If it is not installed yet, skip ahead to the installation section and come back.
a line such as v4.3.0+g1a2b3c4. The +g part is the git commit it was built from.

The four nouns: chart, values, release, revision

Helm's vocabulary is small. Learn these four words precisely and every command reads as a sentence. Two more, repository and registry, appear in the next section.

A chart is a package. It is a folder of files (or that folder compressed into a .tgz archive) containing Kubernetes manifest templates, a Chart.yaml describing the package, and a values.yaml of default settings. A chart on its own does nothing, just as a recipe on its own does not feed anyone. It has a version, which follows semantic versioning (three numbers such as 1.4.0) and is required. It also has an informational appVersion, the version of the application inside, which can be any string.

Values are the inputs. The chart's own values.yaml holds defaults. You override some of them at install time, either in a file of your own (-f my-values.yaml) or one at a time with --set. Helm merges everything into one structure that the templates read as .Values. When two sources disagree, the later one wins, and --set beats -f. We work through the exact order in the values section.

A release is one installed copy of a chart. It has a name you choose and lives in a namespace. The name is unique inside its namespace, which means you can install the same chart twice as two separate releases, blog-staging and blog-prod, and they will not interfere. Release names must be valid DNS labels (lowercase letters, digits and hyphens) and at most 53 characters long. The chart is the recipe; the release is the dish on the table.

A revision is one numbered step in a release's life. Installing creates revision 1. Every upgrade creates the next number. Rolling back does not rewind the counter: it creates a new revision that copies the content of an older one. So if you install, upgrade twice, and roll back to revision 1, you are at revision 4, whose content matches revision 1. By default Helm keeps the last ten revisions per release.

what you keep in git
ChartThe recipe: templates and default values
values-prod.yamlYour choices for this environment
what lives in the cluster
Release "blog" in namespace "prod"Deployment, Service and ConfigMap it created
Revisions 1, 2, 3Stored as Secrets, one per revision

Two more terms are worth adding now, because people use them casually and beginners get lost. The application (or app) is the software inside the chart, for example nginx. The cluster is the Kubernetes installation Helm talks to. A sentence like "I installed the nginx chart as a release called web in the staging namespace, and I'm on revision 3" now makes complete sense: the package, the name, the place, and the point in its history.

A useful shortcut. Whenever a Helm message confuses you, ask which of the four nouns it is about. "Cannot reuse a name that is still in use" is about a release name. "Chart.yaml file is missing" is about a chart. "Values don't meet the specifications" is about values. Most errors sort themselves this way.
Try it
  1. Without looking back, write one sentence each defining chart, values, release and revision.
  2. Answer: if you install one chart three times with three names in one namespace, how many charts and how many releases exist?
  3. Answer: a release is at revision 3 and you roll back to revision 1. What revision number is it at now?
one chart, three releases; and revision 4, because a rollback adds a new revision rather than deleting later ones.

How Helm talks to your cluster

Helm is a client program and nothing else. There is no Helm server running in your cluster, no daemon, no controller. When you run helm install, the helm binary on your machine renders the templates, then calls the Kubernetes API server directly, exactly as kubectl does, and exits.

helm CLIyour laptop or CI
→
kubeconfigwhich cluster, which user
→
API serverchecks RBAC
→
Your objectsplus a release Secret

Three practical consequences follow, and they explain behaviour that otherwise looks mysterious.

Helm uses your credentials and your context. It reads the same kubeconfig file as kubectl (by default ~/.kube/config, or whatever the KUBECONFIG variable points at) and uses the current context, meaning the currently selected cluster and user. If kubectl talks to the wrong cluster, so does Helm. Always check kubectl config current-context before installing anything. Helm also has no more permission than you do; if your account cannot create Deployments in a namespace, helm install there fails with a normal Kubernetes permission error.

Helm stores its memory inside the cluster. After each install or upgrade, Helm writes a record of that revision into the release's namespace, by default as a Secret named like sh.helm.release.v1.web.v3 (release web, revision 3). The record contains the chart, the values you supplied, and the exact manifest that was sent, compressed and base64-encoded. That is how helm history and helm rollback work from any machine: the state is in the cluster, not on your laptop. You can see these Secrets with kubectl get secrets -n <namespace>, and it is worth doing once so they stop being magic.

Helm does nothing between commands. Nothing watches the release, nothing repairs it. A pod crashing is handled by Kubernetes; a hand edit to the Deployment stays until the next helm upgrade. This is simple and predictable, and it is also the reason teams add a GitOps controller on top for continuous correction.

Trap: the wrong cluster. Because Helm follows your current kubeconfig context, a helm uninstall run "in a hurry" can hit production if that was the last context you selected. Get into the habit of running kubectl config current-context first, or pass --kube-context explicitly in scripts.

Since the release record is a Secret that contains the values you passed in, anyone who can read Secrets in that namespace can read your values. Remember this when you are tempted to pass a database password as a value; the later sections and the tips file return to it.

Try it
  1. Run kubectl config current-context and kubectl config get-contexts.
  2. Write down which cluster Helm would act on if you ran it right now.
  3. Later, after your first install, run kubectl get secrets -n <namespace> and find the sh.helm.release.v1 entry.
the name of one context marked current, and later a Secret whose type is helm.sh/release.v1. That Secret is Helm's entire memory of your release.

Installing Helm and checking your setup

Helm is a single static binary, so installation is mostly about choosing the most convenient place to get it. The project publishes signed release archives at get.helm.sh, and most package managers carry it.

On macOS the quickest route is Homebrew:

BASH
brew install helm

On Windows use one of the three common package managers:

POWERSHELL
choco install kubernetes-helm
scoop install helm
winget install Helm.Helm

On Linux you can use the project's install script. Note the script name: the Helm 4 script is get-helm-4. The older get-helm-3 script installs Helm 3, which is a common way to end up on the old version by accident.

BASH
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-4
chmod 700 get_helm.sh
./get_helm.sh

The script picks the latest release. To pin a version, set DESIRED_VERSION=v4.3.0 before running it. If you prefer to do it by hand, download the archive and verify its checksum before unpacking, which is the careful way to install any binary:

BASH
curl -LO https://get.helm.sh/helm-v4.3.0-linux-amd64.tar.gz
curl -LO https://get.helm.sh/helm-v4.3.0-linux-amd64.tar.gz.sha256sum
sha256sum -c helm-v4.3.0-linux-amd64.tar.gz.sha256sum
tar -zxvf helm-v4.3.0-linux-amd64.tar.gz
sudo mv linux-amd64/helm /usr/local/bin/helm

Replace linux-amd64 with darwin-arm64, windows-amd64 and so on to match your machine. sha256sum -c should print OK; anything else means the download is corrupt or has been tampered with.

Other Linux routes exist: sudo dnf install helm on Fedora and sudo snap install helm --classic via Snap. Distribution packages can lag behind the official release, so check the version afterwards.

Security notice for Debian and Ubuntu users. Older tutorials install Helm from an APT repository hosted on baltocdn.com. In May 2026 the Helm project warned that this domain changed ownership and may serve malicious content. If you have a baltocdn.com entry in /etc/apt/sources.list.d/, remove it. The official repository is now hosted on Buildkite; follow the current instructions at helm.sh/docs/intro/install and verify the signing key fingerprint they give before you trust it.

Now verify the installation. Three commands tell you everything:

BASH
helm version --short
helm env
kubectl config current-context

The first prints something like v4.3.0+g<commit>. The full helm version prints a longer structure with the Go version and the Kubernetes client library version it was built against, which matters because Helm 4 is supported against the four newest Kubernetes minor versions around the one it was compiled with (for 4.3 that is 1.34 through 1.37). Using Helm against a Kubernetes release newer than the one it was built for is not recommended; there is no compatibility guarantee.

helm env prints the paths and settings Helm will use. You do not need to memorise it, but read it once. Notice where your repository list lives (HELM_REPOSITORY_CONFIG, a file called repositories.yaml), where downloaded charts are cached (HELM_CACHE_HOME), and which storage driver is selected (HELM_DRIVER, normally secret).

The last check proves Helm can reach a cluster, which is the most common thing a first-time setup gets wrong:

BASH
helm list -A

-A means all namespaces. On a fresh cluster you get an empty table with only the column headers. That is a success: it proves your kubeconfig is valid, the API server is reachable, and your user may list Secrets across namespaces. If you see Error: kubernetes cluster unreachable, the problem is your kubeconfig or network, not Helm; fix it with kubectl first.

Finally, enable shell completion, which saves real typing because Helm has many subcommands and flags. For bash:

BASH
source <(helm completion bash)

Use zsh, fish or powershell in place of bash as appropriate, and add the line to your shell startup file to keep it.

Try it
  1. Install Helm by whichever route suits your system.
  2. Run helm version --short and helm env. Find the path of your repositories.yaml.
  3. Run helm list -A.
a v4.3.0-style version, and either an empty table or a list of releases. If you get "kubernetes cluster unreachable", continue to the next section and create a practice cluster.

A cluster to practise on

Helm needs a Kubernetes cluster to install into. For learning, use a disposable local one so a mistake costs you nothing. Pick whichever you already have or find easiest; all of them give you a kubeconfig context that Helm uses without any further setup.

  • Docker Desktop includes a single-node Kubernetes cluster you switch on in its settings.
  • minikube creates a local cluster with minikube start.
  • kind ("Kubernetes in Docker") runs a cluster as containers; kind create cluster --name helm-lab creates one and selects the context kind-helm-lab.
  • Any small cluster from a cloud provider works too, but watch the bill and remember to delete it.

Whichever you choose, finish with the same two checks:

BASH
kubectl config current-context
kubectl get nodes

The first prints your context name; the second lists at least one node with status Ready. Then create a namespace for practising so your experiments stay in one place and are easy to wipe:

BASH
kubectl create namespace helm-lab

From here on, the guide installs things into namespaces we name explicitly. That is a habit worth having from day one. If you omit -n, Helm uses the default namespace, where things pile up and get forgotten.

Shortcut: let Helm create the namespace. helm install ... -n helm-lab --create-namespace creates the namespace if it does not exist. It is convenient for practice and for one-off installs. Note that Helm does not delete a namespace it created when you uninstall the release.
Try it
  1. Start a local cluster by whichever route you prefer.
  2. Run kubectl get nodes until the node is Ready.
  3. Run helm list -A again.
a Ready node and an empty release table with no error. You now have everything the rest of the guide needs.

Finding charts: repositories, Artifact Hub, and OCI registries

Before you can install someone else's chart you have to find it, and before Helm can fetch it, it needs to know where to look. Charts are distributed in two ways, and you will meet both.

A chart repository is an ordinary web server that hosts an index.yaml file, a catalogue of every chart and version it offers, plus the chart archives themselves. You tell Helm about a repository once with helm repo add, giving it a short local nickname. Helm downloads the catalogue and remembers it in your repositories.yaml.

An OCI registry is the same kind of registry that stores container images, used here to store charts. There is no helm repo add step; you refer to a chart by its full address, which starts with oci://. This is the direction the ecosystem is moving, and many projects now publish charts only this way. It is also how your own team will most likely publish charts, using the container registry it already has.

Artifact Hub (artifacthub.io) is the search engine for both. It is a CNCF-hosted catalogue where you type "postgres" or "prometheus" and get the charts, their documentation, their versions and, importantly, the exact helm repo add command or oci:// address to use. You can also search it from the terminal with helm search hub.

Start with the classic repository route. The Helm project itself publishes a tiny example repository, which is ideal for learning because the chart is small enough to read:

BASH
helm repo add examples https://helm.github.io/examples
helm repo update
helm search repo hello

The first command adds the repository under the nickname examples. The second refreshes the catalogues of every repository you have added; run it before installing anything, because Helm works from the cached catalogue, not the live server. The third searches your local catalogues. You should see something like:

TEXT
NAME                    CHART VERSION   APP VERSION   DESCRIPTION
examples/hello-world    0.1.0           1.16.0        A Helm chart for Kubernetes

Read each column. The chart is named hello-world in the repository examples, so its full reference is examples/hello-world. The chart version (0.1.0) is the version of the packaging. The app version (1.16.0) is the version of the software inside, here a label for the nginx image tag. The two are independent: a chart can be repackaged many times around the same application version.

Other search and listing commands you will use constantly:

BASH
helm repo list                       # the repositories you have added
helm search repo nginx --versions    # every version of every chart matching "nginx"
helm search hub wordpress            # search Artifact Hub (needs internet access)
helm repo remove examples            # forget a repository

helm search repo only shows stable versions by default. Add --devel to include pre-releases such as 1.0.0-rc.1.

For an OCI chart you skip adding anything and use the address directly. Charts in an OCI registry are commonly pulled like this:

BASH
helm show chart oci://registry-1.docker.io/bitnamicharts/nginx

There is no catalogue to search in that case; you know the address, or you found it on Artifact Hub or in the project's documentation.

Trap: stale catalogue. If helm search repo does not show a chart version you know was released, you probably forgot helm repo update. Helm does not refresh on its own.
A note on Bitnami. Many older tutorials install bitnami/nginx or bitnami/wordpress. Bitnami moved its charts to OCI (oci://registry-1.docker.io/bitnamicharts) and changed its free container image catalogue during 2025, so examples that depend on it may fail to pull images. This guide uses a chart we fully control for that reason. If you install a Bitnami chart, check its documentation first.

Region matters a little here. If your employer has data-residency rules, which is common for banks, telecoms and government work in the Gulf and Egypt, check where your chart registry and the images it points to are hosted before you rely on them. Public registries serve from wherever their provider decides; many teams mirror charts and images into a registry inside their own cloud region for exactly this reason. Section "Packaging and sharing" shows how you would push your own chart to one.

Try it
  1. Run the three commands above: repo add, repo update, search repo hello.
  2. Run helm repo list and helm search repo hello --versions.
  3. Open artifacthub.io in a browser and search for "postgresql". Find the install instructions panel.
one chart called examples/hello-world, and on Artifact Hub a page that shows which repository or OCI address each chart comes from.

Your first install

Never install a chart blind. Look at what it is and what it lets you configure. Two show commands do that:

BASH
helm show chart examples/hello-world
helm show values examples/hello-world

show chart prints the Chart.yaml (name, versions, description). show values prints the default values.yaml, which is the complete list of knobs the chart author chose to give you. For hello-world it begins like this:

YAML
replicaCount: 1

image:
  repository: nginx
  pullPolicy: IfNotPresent
  tag: ""

service:
  type: ClusterIP
  port: 80

Read it as a menu. You can change replicaCount, pick a different image, or change the service type and port. Anything not on the menu you cannot change without editing the chart. There are also helm show readme and helm show all, which print the chart's documentation and everything together.

Now install it:

BASH
helm install web examples/hello-world -n helm-lab --create-namespace

Take the command apart, because every install you ever run has this shape: helm install <release-name> <chart> [flags]. Here web is the name you are giving this copy, examples/hello-world is the chart to install, -n helm-lab says which namespace to put it in, and --create-namespace creates that namespace if it does not exist yet.

Helm prints a summary:

TEXT
NAME: web
LAST DEPLOYED: Thu Oct  1 10:15:32 2026
NAMESPACE: helm-lab
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
NOTES:
1. Get the application URL by running these commands:
  export POD_NAME=$(kubectl get pods --namespace helm-lab -l "app.kubernetes.io/name=hello-world,app.kubernetes.io/instance=web" -o jsonpath="{.items[0].metadata.name}")
  ...

Read it line by line. NAME and NAMESPACE are the release's identity. STATUS: deployed means Helm successfully sent everything to the cluster. REVISION: 1 is the first step of its history. NOTES is text the chart author wrote, rendered with your values, telling you what to do next; it comes from the chart's templates/NOTES.txt. Always read it, since it usually contains the exact command to reach the application.

There is one important subtlety about STATUS: deployed. By default in Helm 4, helm install returns as soon as the objects are accepted by Kubernetes. It does not wait for your pods to be running. "Deployed" means "sent", not "healthy". If the image name was wrong, you would still see deployed, and then kubectl get pods would show ImagePullBackOff. To make Helm wait until things are actually ready, add the wait flag:

BASH
helm install web examples/hello-world -n helm-lab --wait

Plain --wait uses the watcher strategy, which watches the objects until Kubernetes reports them ready, or fails after the timeout (five minutes by default, changeable with --timeout 2m). The mid-level guide explains the other strategies. As a beginner, adding --wait to installs and upgrades is a good habit, because it turns a silent failure into a visible one.

Helm 3 habit that changed. In Helm 3, --wait was a simple on/off switch. In Helm 4 it takes a strategy: --wait alone means watcher, while --wait=legacy is the old polling behaviour and --wait=hookOnly (the default when you omit the flag) waits only for hook Pods and Jobs. A Helm 3 command with a bare --wait still works in Helm 4.

Confirm the pods from Kubernetes' side. Helm only created them; Kubernetes runs them:

BASH
kubectl get pods -n helm-lab
kubectl get all -n helm-lab

You should see one pod whose name starts with web-hello-world- and whose status becomes Running, plus a Deployment and a Service of the same base name. The exact names come from the chart's helper templates, so another chart will name things differently.

Let Helm choose the name. helm install examples/hello-world --generate-name (short form -g) invents a release name like hello-world-1712345678. Fine for experiments; use a meaningful name for anything you will keep.
Try it
  1. Run helm show values examples/hello-world and pick two settings you could change.
  2. Run the install command above with --wait.
  3. Run kubectl get pods -n helm-lab and read the pod name.
a release reporting STATUS: deployed and a running pod named after the release and chart. You have installed your first Helm release.

Looking at a release: list, status and get

Once a release exists, a family of read-only commands tells you everything about it. Learn these before any command that changes things, because they are how you find out what state you are actually in.

BASH
helm list -n helm-lab

prints one row per release in that namespace:

TEXT
NAME  NAMESPACE  REVISION  UPDATED                               STATUS    CHART              APP VERSION
web   helm-lab   1         2026-10-01 10:15:32 +0000 UTC         deployed  hello-world-0.1.0  1.16.0

The CHART column combines the chart name and its version; APP VERSION is the application's. Useful variations: helm list -A shows every namespace, helm list -a includes releases in every state (by default, failed and uninstalled ones are hidden), and -o json or -o yaml gives machine-readable output for scripts.

BASH
helm status web -n helm-lab

prints the same summary you saw at install time, including the notes, for the current revision. It is the command to run when you come back to a release after a week and want to remember how to reach it.

Three get subcommands dig into what Helm stored:

BASH
helm get values web -n helm-lab       # only the values YOU supplied
helm get values web -n helm-lab -a    # the merged result, defaults included
helm get manifest web -n helm-lab     # the exact YAML Helm sent to the cluster
helm get notes web -n helm-lab        # the NOTES text again
helm get all web -n helm-lab          # everything in one go

helm get values with no flags shows only your overrides, so for a plain install it prints USER-SUPPLIED VALUES: null. That is not an error; you supplied nothing. -a (or --all) shows the computed values, defaults merged with your overrides, which is what the templates actually saw.

helm get manifest deserves special attention. It is the rendered, final YAML, after every placeholder was filled in. When a deployed application behaves strangely, reading the manifest shows you precisely what Kubernetes was told, with no templating in the way. Compare it with kubectl get deployment web-hello-world -n helm-lab -o yaml and you can see what Kubernetes added on top.

Finally, the release's memory, which you met earlier:

BASH
kubectl get secrets -n helm-lab

You will see a Secret named sh.helm.release.v1.web.v1. That single object is revision 1 of release web.

Habit: status before surgery. Before any upgrade or rollback, run helm list and helm status. It takes five seconds and tells you the current revision and whether the last operation actually finished.
Try it
  1. Run helm list -n helm-lab and helm status web -n helm-lab.
  2. Run helm get values web -n helm-lab, then again with -a. Compare them.
  3. Run helm get manifest web -n helm-lab and find the Deployment's image line.
null for user-supplied values, a full set of defaults with -a, and a manifest containing plain YAML with no {{ }} placeholders left in it.

Changing configuration with values

The installed release uses defaults. To change them you supply values, and Helm offers two ways that you will use daily.

--set overrides a single value on the command line. It uses dots to reach nested keys:

BASH
helm upgrade web examples/hello-world -n helm-lab --set replicaCount=3 --set service.port=8080

-f (or --values) passes a YAML file whose structure mirrors the chart's values.yaml. This is the right tool as soon as you have more than one or two overrides, because the file can be reviewed, committed to git, and reused. Create values-staging.yaml:

values-staging.yaml
replicaCount: 2

image:
  tag: "1.27-alpine"

service:
  port: 8080

and apply it:

BASH
helm upgrade web examples/hello-world -n helm-lab -f values-staging.yaml

The file contains only what differs from the defaults. You do not copy the whole values.yaml in; Helm merges your file on top of the defaults, key by key. That is the point of the design: a small, meaningful file per environment.

Note that these examples use helm upgrade. You have not learned it formally yet, and the next section does, but values only take effect when a release is installed or upgraded, so both commands accept exactly the same values flags. helm install ... -f values-staging.yaml works identically for a first install.

The --set syntax, and its sharp edges

--set has a compact language. The forms you need are:

You want Write
A nested value --set image.tag=1.27
A list --set 'hosts={a.example.com,b.example.com}'
A list item by index --set servers[0].port=80
A comma inside a value --set 'name=one\,two'
A dot inside a key --set 'nodeSelector.kubernetes\.io/os=linux'
Force a string --set-string version=1.10
Read a value from a file --set-file config=./app.conf
Set a JSON value --set-json 'resources={"limits":{"memory":"64Mi"}}'
Remove a default key --set key=null

The reason --set-string exists catches everyone once. --set version=1.10 is parsed as the number 1.1, because YAML treats an unquoted 1.10 as a float and drops the trailing zero. Likewise --set enabled=true gives a boolean and --set id=007 may lose its zeros. When a value must stay text, use --set-string, or quote it in a values file.

Precedence: who wins

When the same key is set in several places, the highest source wins. From lowest to highest:

  1. The chart's own values.yaml (the defaults).
  2. The parent chart's values, if this chart is a dependency of another (covered briefly later).
  3. Files passed with -f, in the order given: a later file overrides an earlier one.
  4. --set, --set-string, --set-file and --set-json flags, which beat every file.

That gives a clean working pattern. Keep a base file, layer an environment file on top, and use --set only for values that vary per run, such as an image tag supplied by your CI pipeline:

BASH
helm upgrade --install web ./hello-web -n helm-lab \
  -f values.yaml -f values-prod.yaml \
  --set image.tag="$GIT_SHA"
Trap: secrets in --set. A value like --set db.password=hunter2 lands in your shell history and, more importantly, in the release record, a Secret that anyone with read access to that namespace can decode. Do not treat values as a safe place for passwords. The safer patterns (a secrets operator, or referencing a Secret that already exists) are covered in the tips and the mid-level guide.

Seeing exactly what was applied

After any change, helm get values web -n helm-lab shows your overrides for the current revision and helm get values web -n helm-lab -a shows the merged result. When a value "did not take effect", compare these two first; nine times out of ten the key was misspelled, so Helm happily stored a value that no template reads. Helm does not warn about unknown keys unless the chart ships a schema that forbids them.

Try it
  1. Create values-staging.yaml as above.
  2. Run helm upgrade web examples/hello-world -n helm-lab -f values-staging.yaml.
  3. Run kubectl get pods -n helm-lab and helm get values web -n helm-lab.
  4. Now run the same upgrade adding --set replicaCount=1. Check the pod count.
two pods after step three, then one pod after step four, because --set outranks the file. That is precedence in action.

Upgrading a release

helm upgrade takes an existing release and moves it to a new chart version, new values, or both. Its shape mirrors install:

BASH
helm upgrade web examples/hello-world -n helm-lab -f values-staging.yaml

Helm renders the chart with the new inputs, compares the result with what it deployed last time, sends only the differences to Kubernetes, and records a new revision. You can confirm the revision number went up with helm list.

There are four different things you might be changing, and it helps to name which one you are doing:

  • Changing values of the same chart version: helm upgrade web <chart> -f new-values.yaml.
  • Moving to a newer chart version: helm upgrade web examples/hello-world --version 0.2.0. Run helm repo update first so the newer version is in your catalogue.
  • Changing your own chart and re-deploying it from a local folder: helm upgrade web ./hello-web.
  • Changing nothing but re-running, for example in a pipeline. This still creates a new revision.

The idempotent install-or-upgrade pattern

Scripts and pipelines should not have to know whether a release already exists. helm upgrade --install (short form -i) does an install if the release is absent and an upgrade if it is present:

BASH
helm upgrade --install web examples/hello-world -n helm-lab --create-namespace -f values-staging.yaml --wait

This is the command that CI/CD pipelines run. The same command is safe to run once or fifty times.

A trap that catches everyone: values do not persist the way you expect

Consider this sequence. You install with --set replicaCount=3. A week later you run helm upgrade web examples/hello-world --set image.tag=1.27-alpine. How many replicas do you get?

In Helm 4, as in Helm 3, each upgrade starts from the chart's defaults plus only the values you pass that time. The earlier replicaCount=3 is gone, and you are back to the default of 1. (The one exception: if you pass no values flags at all, Helm reuses the previous release's values. The moment you pass any override, it starts from the defaults.) The safe approach is to pass the same complete set of values on every upgrade, from files in git. Then the command is the full description of the desired state, and nothing depends on invisible history.

Two flags change this behaviour, and both are traps for beginners. --reuse-values merges the previous release's values with your new overrides but ignores any new defaults added in a newer chart version, which leads to templates reading keys that do not exist. --reset-then-reuse-values is a safer middle path, but the robust rule stays the same: keep your values in files and pass them every time.

Trap: "it reverted my settings". If an upgrade silently changes behaviour you set earlier with --set, you passed only part of the values. Put everything in a values file under version control and pass it on every upgrade.

Making upgrades fail safely

Two flags earn their place on upgrades you care about. --wait (discussed earlier) makes Helm wait for readiness. --rollback-on-failure goes further: if the upgrade fails, Helm automatically rolls back to the previous good revision.

BASH
helm upgrade --install web examples/hello-world -n helm-lab -f values-staging.yaml \
  --rollback-on-failure --timeout 5m
Renamed in Helm 4. --rollback-on-failure was called --atomic in Helm 3. The old name still works but prints Flag --atomic has been deprecated, use --rollback-on-failure instead. Use the new name in anything you write now. Note that --rollback-on-failure turns on --wait with the watcher strategy for you.
Try it
  1. Run helm upgrade web examples/hello-world -n helm-lab --set replicaCount=3 --wait.
  2. Run it again with only --set image.tag=1.27-alpine (no replica flag). Count the pods.
  3. Run helm list -n helm-lab and note the revision.
three pods, then one pod, and a revision number that increased each time. The replica count reverted because the second upgrade did not repeat it.

History and rollback

Every install and upgrade left a numbered revision. helm history lists them:

BASH
helm history web -n helm-lab
TEXT
REVISION  UPDATED                   STATUS      CHART              APP VERSION  DESCRIPTION
1         Thu Oct  1 10:15:32 2026  superseded  hello-world-0.1.0  1.16.0       Install complete
2         Thu Oct  1 10:22:10 2026  superseded  hello-world-0.1.0  1.16.0       Upgrade complete
3         Thu Oct  1 10:24:41 2026  deployed    hello-world-0.1.0  1.16.0       Upgrade complete

Only one revision is deployed at a time, the live one. Earlier successes are superseded. A revision whose upgrade failed shows failed. The DESCRIPTION column records what happened, and you can write your own description on upgrades with --description "raise replicas for demo day", which is useful context for your future self.

To see what a past revision contained, use the --revision flag on get:

BASH
helm get values web -n helm-lab --revision 2
helm get manifest web -n helm-lab --revision 2

To undo a change:

BASH
helm rollback web 2 -n helm-lab

This rolls release web back to the state of revision 2. Omit the number (helm rollback web -n helm-lab) and Helm goes back to the immediately previous revision. Afterwards helm history shows a new revision 4 whose description says "Rollback to 2". History is never rewritten; it only grows, which is why you can always see what happened.

Rollback restores Kubernetes objects and the values of that revision. It cannot restore things outside Helm's control. If revision 3 ran a database migration, rolling back the chart does not undo the migration. If someone manually deleted a PersistentVolumeClaim, it is gone. Treat rollback as "redeploy the old description", not as a time machine.

Add --wait to rollbacks, too, so you know when the old version is actually serving again. In Helm 4.3 you can also attach an explanation, for example --description "rolled back: bad image tag", up to 256 characters.

Keep history short but not zero. Helm keeps the last ten revisions per release by default (change it with --history-max). That is plenty. Each revision is a Secret in the cluster, so an unbounded history of a big chart wastes space.
Try it
  1. Run helm history web -n helm-lab.
  2. Pick the revision that had three replicas and run helm rollback web <that number> -n helm-lab --wait.
  3. Run helm history again and kubectl get pods -n helm-lab.
a new latest revision marked deployed with a rollback description, and the pod count of the revision you chose.

Uninstalling a release

When you no longer want a release:

BASH
helm uninstall web -n helm-lab

Helm deletes the objects it created for that release (the Deployment, the Service, and so on) and then removes the release records. Afterwards helm list -n helm-lab no longer shows it and kubectl get all -n helm-lab is empty of those objects. Because Helm knows what it created, it removes exactly that and nothing else; it does not sweep the namespace.

Some details worth knowing:

  • The namespace stays. Even if --create-namespace made it, helm uninstall does not delete it. Remove it yourself with kubectl delete namespace helm-lab when you are done.
  • --keep-history removes the objects but keeps the release records, so helm list -a shows the release as uninstalled and helm rollback can bring it back. Without this flag, history is deleted with the release, and you cannot roll back.
  • Names stay reserved when history is kept. Installing a new release with the same name after --keep-history fails with cannot reuse a name that is still in use. Use helm upgrade --install (which revives it), uninstall without history, or choose another name.
  • Persistent data. A PersistentVolumeClaim created by a chart's StatefulSet is deliberately not always deleted, because deleting data silently would be dangerous. Check with kubectl get pvc -n <namespace> after uninstalling a database chart.
  • Newer safety check. Since Helm 4.3, uninstall checks that each object actually carries this release's ownership labels before deleting it, and skips others with the warning skipping delete of resource not owned by this release. That protects you from deleting something another release or person created.

Preview an uninstall with helm uninstall web -n helm-lab --dry-run if you are unsure what would go.

Trap: the wrong release, the wrong namespace. helm uninstall web without -n looks in the default namespace, where your release probably is not, and answers Error: uninstall: Release not loaded: web: release: not found. The fix is the right -n. helm list -A shows where everything lives.
Try it
  1. Run helm uninstall web -n helm-lab and then helm list -n helm-lab.
  2. Run kubectl get all -n helm-lab.
  3. Install again with helm install web examples/hello-world -n helm-lab --wait.
an empty list, no leftover objects, and a clean revision 1 on the reinstall. Uninstalling and reinstalling is how you reset an experiment.

Anatomy of a chart

So far you have used somebody else's chart. The real leverage comes from writing your own, and that starts with knowing what is inside one. Helm can scaffold a complete example:

BASH
helm create mychart

This makes a folder called mychart with a working, installable sample application (an nginx Deployment with a Service, and optional Ingress and autoscaler). The exact files vary slightly between Helm versions, but the layout is stable:

TEXT
mychart/
  Chart.yaml          metadata: name, version, description
  values.yaml         the default values (the "menu" of settings)
  .helmignore         files to leave out when packaging
  charts/             dependencies live here (subcharts)
  templates/
    deployment.yaml   Kubernetes manifests with {{ placeholders }}
    service.yaml
    ingress.yaml
    _helpers.tpl      reusable named snippets (not rendered on their own)
    NOTES.txt         the message printed after install
    tests/
      test-connection.yaml   a Pod that checks the app works

Go through the pieces in the order Helm cares about them.

Chart.yaml is the chart's identity card. Its required fields are apiVersion (always v2 for current charts), name, and version. Everything else is optional but useful: description, type (application by default, or library for charts that only share templates), and appVersion. The key line to remember is that version is the chart's own version and must change whenever the chart changes, because registries and helm upgrade --version rely on it being unique.

values.yaml holds the defaults. A good one is commented, small, and grouped: image: has its repository, tag and pull policy together, service: has its type and port together. This file is your chart's public interface. Names you put here become promises to everyone who overrides them.

templates/ is the heart. Every file here (except those starting with an underscore and NOTES.txt) is treated as a Kubernetes manifest template. Helm runs each through the template engine, joins the results, and sends them to the cluster. Files starting with _ are not turned into objects; they hold named snippets other templates reuse, which is why the conventional _helpers.tpl has that name.

templates/NOTES.txt is plain text, also templated, printed after install and upgrade. templates/tests/ holds manifests annotated as tests, which helm test runs. charts/ holds other charts your chart depends on. .helmignore works like .gitignore: patterns for files that should not end up in the packaged archive, such as editor backups.

The generated scaffold is a good reference but is larger than a beginner needs. To understand every line, we will now write a smaller chart by hand.

Charts are just folders. There is no compile step and no registration. If a directory has a valid Chart.yaml, Helm treats it as a chart: helm install demo ./mychart, helm template demo ./mychart and helm lint ./mychart all work on the path directly.
Try it
  1. Run helm create mychart in an empty folder.
  2. Open mychart/values.yaml, mychart/Chart.yaml and mychart/templates/service.yaml.
  3. In service.yaml, find every {{ ... }} and look up where in values.yaml it comes from.
each placeholder such as .Values.service.port maps to a key in values.yaml. That mapping is the entire templating system.

Writing your first chart, step by step

We will build a chart called hello-web that serves a web page with nginx. The page's headline comes from a value, so one chart can greet different audiences. It has a ConfigMap (holding the page), a Deployment (running nginx and mounting the page) and a Service (giving it an address). Build it a file at a time so each piece is understood.

Start with the folder and the two files every chart needs:

BASH
mkdir -p hello-web/templates
cd hello-web
Chart.yaml
apiVersion: v2
name: hello-web
description: A tiny web page served by nginx
type: application
version: 0.1.0
appVersion: "1.27"

The appVersion is quoted on purpose. YAML would read an unquoted 1.27 as a number, and a value such as 1.10 would silently become 1.1. Quote version strings everywhere.

Now the defaults. Decide what a user might reasonably want to change, and expose exactly that:

values.yaml
# Number of pod copies to run
replicaCount: 1

image:
  repository: nginx
  tag: "1.27-alpine"
  pullPolicy: IfNotPresent

# The headline shown on the web page
message: "Hello from Helm"

service:
  type: ClusterIP
  port: 80

resources:
  requests:
    cpu: 50m
    memory: 32Mi
  limits:
    memory: 64Mi

Next, the ConfigMap template. It is ordinary Kubernetes YAML with a few placeholders in double curly braces:

templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "hello-web.fullname" . }}
  labels:
    {{- include "hello-web.labels" . | nindent 4 }}
data:
  index.html: |
    <!doctype html>
    <h1>{{ .Values.message }}</h1>
    <p>Release {{ .Release.Name }}, namespace {{ .Release.Namespace }}, revision {{ .Release.Revision }}.</p>

Three kinds of placeholder appear, and they are the ones you will write most. {{ .Values.message }} reads a value you defined. {{ .Release.Name }} reads a fact Helm supplies about the release itself (its name, namespace and revision number). {{ include "hello-web.fullname" . }} calls a named snippet that we define in a moment. The trailing dot passes the current context to the snippet.

The Deployment is the longest template, but every line in it is ordinary Kubernetes except the placeholders:

templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "hello-web.fullname" . }}
  labels:
    {{- include "hello-web.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "hello-web.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "hello-web.selectorLabels" . | nindent 8 }}
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      containers:
        - name: web
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: 80
          readinessProbe:
            httpGet:
              path: /
              port: http
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          volumeMounts:
            - name: page
              mountPath: /usr/share/nginx/html
      volumes:
        - name: page
          configMap:
            name: {{ include "hello-web.fullname" . }}

Two lines deserve an explanation because they are idioms you will reuse in nearly every chart. The resources: block uses toYaml, which converts a whole structure from your values into YAML text, and nindent 12, which starts a new line and indents that text by twelve spaces so it lines up under resources:. Without the correct indentation, YAML breaks. The checksum/config annotation hashes the rendered ConfigMap into the pod template. Kubernetes only restarts pods when the pod template changes; editing a ConfigMap alone does not change it. By embedding the ConfigMap's hash, any change to the page changes the annotation, which triggers a rolling restart. It is a small trick with a large payoff.

The Service gives the pods a stable address:

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

The selector must match the pods' labels; that is how a Service finds its pods. Because both use the same named snippet, hello-web.selectorLabels, they can never drift apart. This is the main reason helpers exist.

Finally, define those snippets. Files beginning with an underscore are not rendered as manifests, so helper definitions live there:

templates/_helpers.tpl
{{/* Name shared by every object in this release, e.g. "demo-hello-web" */}}
{{- define "hello-web.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end }}

{{/* Labels that identify which pods belong to this release */}}
{{- define "hello-web.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/* Labels put on every object */}}
{{- define "hello-web.labels" -}}
{{ include "hello-web.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{- end }}

A define block gives a name to a snippet. The first, hello-web.fullname, builds a name from the release name and the chart name, cuts it to 63 characters (the Kubernetes limit for many names) and removes a trailing hyphen. Prefixing snippet names with the chart name, as here, avoids collisions when charts are combined. Release demo of this chart produces objects named demo-hello-web, so two releases in one namespace never fight over names.

Add a notes file so users know what to do next:

templates/NOTES.txt
Release {{ .Release.Name }} is installed in namespace {{ .Release.Namespace }}.

To view the page from your laptop, run:

  kubectl port-forward -n {{ .Release.Namespace }} service/{{ include "hello-web.fullname" . }} 8080:{{ .Values.service.port }}

then open http://localhost:8080

That is the whole chart: two metadata files and five templates. Before it goes near a cluster, the next section shows how to preview and check it. First, see the tree you just built:

TEXT
hello-web/
  Chart.yaml
  values.yaml
  templates/
    _helpers.tpl
    configmap.yaml
    deployment.yaml
    service.yaml
    NOTES.txt
Try it
  1. Create the folder and the files above exactly as shown.
  2. Run helm lint hello-web from the folder above it.
  3. Run helm template demo hello-web and read the output.
lint reports 1 chart(s) linted, 0 chart(s) failed (plus an informational note that an icon is recommended), and template prints three YAML documents named demo-hello-web with every placeholder replaced by a real value.

The template language in ten minutes

You have now seen the language in use. Here it is systematically, because nearly all chart-writing is a handful of ideas.

Helm templates use Go's text/template engine with a large library of extra functions called Sprig, plus Helm's own additions. Anything inside {{ }} is an action; everything outside is copied as-is. There are only a few kinds of action.

Reading data. The dot at the start of a name means "the current data". At the top of a template, the dot holds everything Helm gives you:

Expression What it gives you
.Values.x.y A value from the merged values
.Release.Name The release name; also .Release.Namespace, .Release.Revision, .Release.IsInstall, .Release.IsUpgrade
.Chart.Name, .Chart.Version, .Chart.AppVersion Fields from Chart.yaml, capitalised
.Capabilities.KubeVersion The Kubernetes version of the target cluster
.Files.Get "name" The contents of a file bundled in the chart
.Template.Name The path of the template being rendered

Pipelines. A vertical bar passes a value to a function, like a Unix pipe. {{ .Values.image.tag | quote }} wraps the tag in quotes. Functions chain left to right: {{ .Values.name | upper | trunc 10 }}.

Defaults and requirements. {{ .Values.image.pullPolicy | default "IfNotPresent" }} supplies a fallback when a value is empty. {{ required "image.tag is required" .Values.image.tag }} does the opposite: it aborts the render with your message if the value is missing, which is a kind way to tell a user what they forgot.

Conditionals. Include a block only when something is true:

YAML
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
# ...
{{- end }}

A value is "false" if it is false, 0, empty, or missing. That is how optional objects work: the whole file is wrapped in an if, and when the flag is off it renders to nothing.

Loops. range repeats a block for each item in a list or map:

YAML
env:
  {{- range $key, $val := .Values.env }}
  - name: {{ $key }}
    value: {{ $val | quote }}
  {{- end }}

Scope changes, and the dollar sign. Inside a range or with block, the dot changes to mean the current item, so .Values and .Release are no longer reachable through it. The variable $ always means the root. Write {{ $.Release.Name }} inside loops. Forgetting this is the most common source of "nil pointer" errors in beginner charts.

Whitespace. A hyphen inside the braces trims whitespace: {{- removes whitespace before, -}} removes it after. Without trimming, a line holding only an {{ if }} leaves a blank line in the output. YAML tolerates blank lines but not wrong indentation, so the trimming you need most is the nindent function from earlier, which produces a newline followed by the indented text.

Named templates and include. define names a snippet, and include "name" . inserts it. There is also a keyword template that does similar work, but it cannot be piped into other functions, so use include. That is why {{ include "x" . | nindent 4 }} is everywhere.

Comments. {{/* like this */}} is a template comment, removed from the output. A YAML comment with # is passed through to the output and visible in helm get manifest.

Trap: YAML is indentation-sensitive, templates are not. The engine substitutes text; it does not know you are inside a YAML mapping. When you insert a multi-line value, you must indent it yourself, usually with nindent. When the result is broken, helm template --debug prints the invalid output so you can see exactly which line is misaligned.
Try it
  1. In hello-web/templates/deployment.yaml, change {{ .Values.image.pullPolicy }} to {{ .Values.image.pullPolicy | default "Always" | quote }}.
  2. Render with helm template demo hello-web and find the pull policy line.
  3. Add --set image.pullPolicy=null and render again.
imagePullPolicy: "IfNotPresent" with quotes, then "Always" when the value was removed. The pipeline and the default did exactly what the pipeline reads like.

Previewing and checking a chart before it touches a cluster

The most valuable habit in Helm is to look at what will be sent before sending it. Three commands form a ladder, from fastest and least certain to slowest and most certain.

helm lint checks that the chart is well formed: valid Chart.yaml, parseable templates, sensible structure.

BASH
helm lint hello-web
TEXT
==> Linting hello-web
[INFO] Chart.yaml: icon is recommended

1 chart(s) linted, 0 chart(s) failed

[INFO] lines are suggestions; [WARNING] lines deserve a look; [ERROR] lines fail the lint. Add --strict to treat warnings as errors in CI, and --set key=value or -f to lint with particular values. Lint does not contact a cluster.

helm template renders the chart locally and prints the YAML. It is the single most useful debugging command you have, because it shows exactly what Helm would send:

BASH
helm template demo hello-web -n helm-lab
helm template demo hello-web -n helm-lab --set replicaCount=3 --show-only templates/deployment.yaml

The first argument is a release name (a stand-in; nothing is installed). --show-only limits the output to one file, and --debug shows the output even when it is invalid YAML, so you can diagnose syntax errors. helm template needs no cluster at all, which is why pipelines use it for quick checks.

There is an important limit: helm template has no cluster to ask, so anything that depends on the cluster (such as whether a resource kind exists) is not validated. That is what the third rung is for.

helm install --dry-run=server renders the chart and sends it to the real API server for validation, but does not save anything:

BASH
helm install demo hello-web -n helm-lab --dry-run=server --debug

The API server checks the objects against the actual schema of your cluster, including custom resource definitions. It catches errors that local rendering cannot, such as a wrong field name, a missing CRD or a forbidden action. A plain --dry-run (or --dry-run=client) renders locally without contacting the cluster.

Helm 4 change. The older spelling helm template --validate is deprecated and prints use '--dry-run=server' instead. Prefer the new form. Also note that --dry-run on install and upgrade now takes a value (none, client, server); a bare --dry-run still works and means client.

When a render fails, the error points at a file, a line and a column. Here are two you will meet, with real wording.

A missing parent key produces a Go template error that ends like this:

TEXT
nil pointer evaluating interface {}.text

It happens when a template reads .Values.msg.text but msg was never defined, so there is nothing to take .text from. Fix it by defining msg in values.yaml, or guard the access with {{ if .Values.msg }}.

Bad indentation produces a YAML error:

TEXT
Error: YAML parse error on hello-web/templates/deployment.yaml: error converting YAML to JSON: yaml: line 35: mapping values are not allowed in this context

Use --debug flag to render out invalid YAML

Follow its advice: run helm template --debug, find the reported line in the printed output, and check the indentation around the last inserted value. The cause is nearly always toYaml without nindent, or the wrong number passed to nindent.

The preview ladder. Lint for structure, template to read the output, --dry-run=server to let the real cluster object. Run all three before your first install of any chart you wrote.
Try it
  1. Run helm lint hello-web, then helm template demo hello-web --set replicaCount=3 --show-only templates/deployment.yaml.
  2. Break something on purpose: in deployment.yaml change nindent 12 to indent 2. Run helm template demo hello-web.
  3. Run it again with --debug, find the bad line, then undo the change.
replicas: 3 in the first render, and a YAML parse error in step two with the invalid output visible once --debug is on.

Installing your chart, testing it, and iterating

Now put the chart into the cluster. The command is the same as before, except that the chart argument is a folder path:

BASH
helm install demo ./hello-web -n helm-lab --create-namespace --wait
kubectl get pods -n helm-lab

When the pod is Running, open the page as the notes told you:

BASH
kubectl port-forward -n helm-lab service/demo-hello-web 8080:80

Visit http://localhost:8080 in a browser (or curl http://localhost:8080 from another terminal). You should see "Hello from Helm" and a line naming the release, namespace and revision. port-forward stays in the foreground; press Ctrl-C to stop it.

Now experience the development loop. Change the headline from the command line and upgrade:

BASH
helm upgrade demo ./hello-web -n helm-lab --set message="Hello, MLOps MENA" --wait

Refresh the page. The headline changed and the page now says revision 2. The pods restarted because of the checksum/config annotation: the rendered ConfigMap changed, so its hash changed, so the pod template changed. Remove that annotation and repeat the experiment to see what it prevents: the ConfigMap would update but the pods would keep serving stale content until something else restarted them.

Chart tests

A deployed release that looks fine may not work. Helm has a built-in way to check: a manifest in templates/tests/ annotated "helm.sh/hook": test, run by helm test. Create one that fetches the page from inside the cluster:

templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: {{ include "hello-web.fullname" . }}-test
  labels:
    {{- include "hello-web.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": test
spec:
  restartPolicy: Never
  containers:
    - name: wget
      image: busybox:1.36
      command: ["wget"]
      args: ["-qO-", "http://{{ include "hello-web.fullname" . }}:{{ .Values.service.port }}/"]

The test is a Pod that runs to completion. If it exits with status 0 the test passes; any other exit code fails it. Upgrade so the chart includes it, then run:

BASH
helm upgrade demo ./hello-web -n helm-lab --wait
helm test demo -n helm-lab --logs

--logs prints the pod's output, so you see the fetched HTML. Test pods are not part of the release's normal resources; they exist only when you run helm test and stay afterwards for inspection. Helm's delete policy for hooks defaults to removing a previous hook pod of the same name before creating a new one, so running the test repeatedly works.

Hooks in one paragraph

A test is one kind of hook: a manifest that Helm runs at a particular moment rather than deploying as part of the release. The annotation "helm.sh/hook" names the moment: pre-install, post-install, pre-upgrade, post-upgrade, pre-rollback, post-rollback, pre-delete, post-delete, or test. The classic use is a database migration Job that must finish before an upgrade proceeds. You can order several hooks with "helm.sh/hook-weight" (a number in quotes; lower runs first) and control cleanup with "helm.sh/hook-delete-policy". Hooks are powerful and easy to misuse; the mid-level guide treats them properly. For now know that a hook's resources are not tracked as part of the release, so helm uninstall does not automatically delete them unless a delete policy says so.

Trap: a hook that hangs the release. If a pre-upgrade hook Job never finishes, the upgrade waits until the timeout and then fails. When an upgrade seems stuck, check kubectl get jobs,pods -n <namespace> for a hook that is still running or failing.
Try it
  1. Install hello-web as release demo with --wait, then port-forward and view the page.
  2. Upgrade with --set message="Hello, MLOps MENA" and refresh.
  3. Add the test file, upgrade, then run helm test demo -n helm-lab --logs.
the new headline with revision 2, then Phase: Succeeded and the page HTML from helm test. You have a chart that installs, upgrades and verifies itself.

Dependencies: charts that use other charts

Real applications need other things: a web app needs a database, a cache, perhaps a message queue. Rather than copying their YAML into your chart, you declare them as dependencies. Each dependency is itself a chart, called a subchart, installed together with yours as part of the same release.

You declare dependencies in Chart.yaml:

Chart.yaml
apiVersion: v2
name: hello-web
version: 0.2.0
appVersion: "1.27"
dependencies:
  - name: postgresql
    version: "~16.0.0"
    repository: oci://registry-1.docker.io/bitnamicharts
    condition: postgresql.enabled

Each entry names the chart, a version or version range, and where to find it. Possible repositories are an https:// repository URL, an oci:// address (without the chart name, which comes from name:), or file://../path for a chart in a neighbouring folder. The condition line makes the dependency optional: it is installed only if postgresql.enabled is true in your values.

Then download the subcharts into your chart's charts/ folder:

BASH
helm dependency update ./hello-web

This fetches the charts, stores them in charts/ as archives, and writes a Chart.lock file recording the exact resolved versions, like a lock file in npm or pip. Commit Chart.lock; use helm dependency build when you want to rebuild from it exactly, and helm dependency list to see the current state.

If you forget this step, Helm tells you clearly:

TEXT
Error: found in Chart.yaml, but missing in charts/ directory: postgresql

The fix is helm dependency update, or add --dependency-update to the install command.

You configure a subchart through your own values, under a key named after the subchart:

values.yaml
postgresql:
  enabled: true
  auth:
    database: hello

Everything under postgresql: is passed to the subchart's own values. A special top-level key, global:, is visible to the parent and every subchart, which is how you push one setting (for example a registry mirror) into all of them.

Keep it small at first. Dependencies make installs bigger and failures harder to localise: one failing subchart fails the whole release. As a beginner, prefer installing a database as its own release and pointing your app at it by service name, rather than nesting it. The mid-level guide compares the approaches.
Try it
  1. Create a second chart with helm create backend next to hello-web.
  2. In hello-web/Chart.yaml, add a dependency on it with repository: "file://../backend" and the matching version.
  3. Run helm dependency update hello-web and helm dependency list hello-web.
a charts/backend-0.1.0.tgz file and a Chart.lock, and a dependency listing whose status reads ok.

Packaging and sharing a chart

A chart folder is fine for yourself. To hand it to a colleague, a pipeline, or a cluster elsewhere, you package it into a single versioned archive and publish that somewhere they can reach.

BASH
helm package ./hello-web
TEXT
Successfully packaged chart and saved it to: /home/you/hello-web-0.1.0.tgz

The filename is built from the chart name and the version in Chart.yaml. That is why bumping version on every change matters: two different charts must never share a version, because anything that cached 0.1.0 would never see your fix. helm package also accepts --version and --app-version to override those fields, which CI pipelines use to stamp a build.

You can install the archive directly, which proves it is self-contained:

BASH
helm install demo2 ./hello-web-0.1.0.tgz -n helm-lab

Now publish it. The modern route is an OCI registry. First log in; in Helm 4 the login argument is only a host name, with an optional port, not a full URL:

BASH
helm registry login registry.example.com -u myuser --password-stdin

The --password-stdin flag reads the password from standard input instead of the command line, so it does not land in your shell history. Typical use in a pipeline is echo "$REGISTRY_TOKEN" | helm registry login ghcr.io -u myuser --password-stdin.

Helm 4 change. helm registry login oci://registry.example.com and https://... forms are rejected. Give it the bare host, such as registry.example.com or localhost:5000.

Then push the archive. The destination is the registry and a path; Helm adds the chart name and uses the chart version as the tag:

BASH
helm push hello-web-0.1.0.tgz oci://registry.example.com/charts

The chart now lives at oci://registry.example.com/charts/hello-web with tag 0.1.0. Anyone with access installs it by that address:

BASH
helm install demo oci://registry.example.com/charts/hello-web --version 0.1.0 -n helm-lab

Always pass --version, so what you deploy is what you tested. Helm 4 can also install by digest, the content hash of the artifact (oci://.../hello-web@sha256:<digest>), which is immutable and cannot be changed after the fact the way a tag can.

To try the whole flow without an account anywhere, run a throwaway registry on your machine:

BASH
docker run -d -p 5000:5000 --name registry registry:2
helm push hello-web-0.1.0.tgz oci://localhost:5000/charts --plain-http
helm show chart oci://localhost:5000/charts/hello-web --version 0.1.0 --plain-http

--plain-http is needed because this local registry has no TLS. Never use it, or --insecure-skip-tls-verify, against a real registry.

For a team, the registry you choose is usually the one your cloud provider offers (ECR, Artifact Registry, ACR, or Oracle's OCIR), the one built into your code host (GHCR) or a self-hosted one such as Harbor. If your organisation has regional or sovereignty constraints, choose a registry hosted in an approved region, and mirror public charts into it rather than pulling from the internet at deploy time.

The older method, a classic repository, is still common: you generate an index.yaml with helm repo index ./dir --url https://charts.example.com and host the folder on any web server, GitHub Pages or an object-storage bucket. You do not need it to start; the OCI route is simpler.

Try it
  1. Run helm package ./hello-web and confirm the .tgz exists.
  2. Start the local registry with the docker run command above and push the archive with --plain-http.
  3. Install from the registry: helm install demo3 oci://localhost:5000/charts/hello-web --version 0.1.0 --plain-http -n helm-lab.
a release installed straight from an OCI address. That is how your team's charts travel from a build pipeline to a cluster.

Configuration, files and environment variables

Helm has very little configuration of its own, which is a feature. Three places matter.

Files on your machine. Helm keeps three directories: a cache (downloaded charts and repository catalogues), a config directory (your repository list and registry logins) and a data directory (plugins). On Linux they are ~/.cache/helm, ~/.config/helm and ~/.local/share/helm; on macOS they live under ~/Library; on Windows under %APPDATA% and %TEMP%. helm env prints the real paths on your system. The two files you might edit or inspect are repositories.yaml (the repositories from helm repo add) and registry/config.json (registry credentials, in the same format as Docker's). Treat the latter like a password file.

Environment variables. Most settings can be supplied as HELM_* variables instead of flags, which is convenient in pipelines. The ones a beginner meets:

Variable What it does
KUBECONFIG Which kubeconfig file to use (shared with kubectl)
HELM_KUBECONTEXT Which context within it, like --kube-context
HELM_NAMESPACE Default namespace, like -n
HELM_DEBUG Turns on debug output, like --debug
HELM_MAX_HISTORY How many revisions to keep per release (default 10)
HELM_DRIVER Where release records are stored: secret (default), configmap, memory
HELM_CACHE_HOME, HELM_CONFIG_HOME, HELM_DATA_HOME Move those three directories
NO_COLOR or HELM_COLOR Control coloured output

Global flags that work on every command include -n/--namespace, --kube-context, --kubeconfig, and --debug. --debug is a close friend: it prints extra detail about what Helm is doing, which is often the fastest way to see why something fails.

One more flag worth knowing is --timeout, the maximum time Helm waits for each Kubernetes operation (five minutes by default). If your images are large or your cluster is slow, raise it rather than retrying blindly.

In CI, set the namespace and context explicitly. Rely on -n and --kube-context in every command rather than on whatever the machine's kubeconfig happened to select. A pipeline that depends on implicit state will deploy to the wrong place eventually.
Try it
  1. Run helm env and find your config directory.
  2. Run HELM_NAMESPACE=helm-lab helm list with no -n flag.
  3. Run helm list --debug and compare the output with the plain command.
the same releases listed without -n, because the variable supplied the namespace, and a few extra log lines with --debug.

Common errors and how to read them

Helm errors look alarming but follow a pattern: a prefix naming the operation (INSTALLATION FAILED, UPGRADE FAILED), then the cause, often quoting a Kubernetes message. Read from the end; the last clause is usually the real reason. The table lists the ones beginners meet, with real wording.

Error as printed What it means What to do
INSTALLATION FAILED: cannot reuse a name that is still in use A release of that name already exists in this namespace (possibly failed, or uninstalled with history kept) Use helm upgrade --install, pick another name, or uninstall the old one
UPGRADE FAILED: "web" has no deployed releases Every revision is failed or pending, typically after a failed first install helm uninstall web then install again; use --rollback-on-failure on first installs
UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress A previous run was killed and left the release pending-... helm history web, then helm rollback web <last-good-revision>
kubernetes cluster unreachable: ... Wrong or missing kubeconfig, wrong context, or expired login kubectl config current-context; fix with kubectl first
Error: Chart.yaml file is missing You pointed at a folder that is not a chart Point at the chart root (the folder containing Chart.yaml)
found in Chart.yaml, but missing in charts/ directory Dependencies were never downloaded helm dependency update ./chart
YAML parse error on ... mapping values are not allowed Bad indentation in a template helm template --debug; check nindent
nil pointer evaluating interface {}.x A template reads a value whose parent key does not exist Define it in values.yaml, or guard with if
execution error at (...): image.tag is required A required call fired Supply the value
chart requires kubeVersion: >=1.33.0-0 which is incompatible with Kubernetes v1.31.4 The chart demands a newer cluster Use an older chart version or a newer cluster
values don't meet the specifications of the schema(s) A value failed the chart's values.schema.json Fix the value to match the schema
unable to continue with install: ConfigMap "foo" in namespace "web" exists and cannot be imported into the current release: invalid ownership metadata An object of that kind and name exists and was not created by this release See below
looks like "https://..." is not a valid chart repository Wrong URL, no network, or no index.yaml Check the URL in a browser; helm repo list
context deadline exceeded or resource not ready With --wait, pods did not become ready in time kubectl get pods, kubectl describe pod, kubectl logs; then raise --timeout only if the pods are merely slow

Two of these deserve a longer explanation.

"Another operation is in progress." Helm marks a release pending-install or pending-upgrade while working, and back to deployed or failed when done. If the process is killed midway (a pipeline timeout, Ctrl-C, a closed laptop) the marker stays, and Helm refuses to start another operation so two runs cannot collide. Look at helm history <release>; if the top revision is pending and nothing is running, roll back to the last good revision. Avoid the situation by making your pipeline's job timeout longer than Helm's --timeout.

Existing resources and ownership. Helm labels and annotates everything it creates with the release name, namespace and app.kubernetes.io/managed-by: Helm. If you try to install a chart that wants to create an object that already exists without those markers, Helm refuses rather than overwriting it, with a long message containing invalid ownership metadata. This commonly happens when you created something with kubectl apply and later put it in a chart. Your choices are to delete the old object, add the missing labels and annotations with kubectl, or tell Helm to adopt it with --take-ownership. Choose deliberately; adoption means the release now controls that object.

A debugging routine

When something fails, work down this list instead of guessing:

  1. Read the error from the end.
  2. helm list -a -n <ns> and helm history <release> -n <ns>: what state is the release in?
  3. helm template with the same values: does it render, and is the output what you expected?
  4. helm install --dry-run=server --debug: does the cluster accept it?
  5. kubectl get pods, kubectl describe pod <name> and kubectl logs <name>: if Helm succeeded but the app is broken, it is now a Kubernetes question, and the Kubernetes guide at /student-guides/kubernetes picks up there.
  6. helm get manifest <release> and helm get values <release> -a: compare what was deployed with what you intended.
Try it
  1. Install demo from ./hello-web. Run the exact same install command a second time.
  2. Read the error and use the table to choose the fix.
  3. Now run helm install bad ./does-not-exist -n helm-lab and read that error.
cannot reuse a name that is still in use for the first, fixed by helm upgrade --install, and a "path not found" style error for the second that has nothing to do with the cluster.

Putting it all together

Here is one small end-to-end project that uses everything above: take the hello-web chart and run it for two audiences, a "staging" and a "production" release, each with its own values, then upgrade, break, recover and clean up. It takes about fifteen minutes.

Step 1. Prepare two values files, each only the differences from the defaults.

values-staging.yaml
replicaCount: 1
message: "Hello from STAGING"
values-production.yaml
replicaCount: 2
message: "Hello from PRODUCTION"
resources:
  requests:
    cpu: 100m
    memory: 64Mi
  limits:
    memory: 128Mi

Step 2. Check before you deploy. Lint, render both, and dry-run against the cluster.

BASH
helm lint ./hello-web -f values-production.yaml
helm template prod ./hello-web -f values-production.yaml | grep -E "replicas|memory"
helm install prod ./hello-web -n prod --create-namespace -f values-production.yaml --dry-run=server

Step 3. Install both environments. Same chart, two namespaces, two releases.

BASH
helm upgrade --install staging ./hello-web -n staging --create-namespace -f values-staging.yaml --wait
helm upgrade --install prod ./hello-web -n prod --create-namespace -f values-production.yaml --wait
helm list -A

helm list -A shows two releases of one chart, which is the whole point of separating the chart from its values.

Step 4. Verify with the built-in test. (This needs the templates/tests/test-connection.yaml file from the testing section; add it and upgrade both releases first.)

BASH
helm test staging -n staging --logs
helm test prod -n prod --logs

Step 5. Ship a change safely. Change the production headline and upgrade with automatic rollback. Because you pass the same values file again, nothing else reverts.

BASH
helm upgrade prod ./hello-web -n prod -f values-production.yaml \
  --set message="Hello, launch day" --rollback-on-failure --wait --description "launch day banner"
helm history prod -n prod

Step 6. Break it on purpose, then recover. Point production at an image tag that does not exist, without the rollback flag, and watch it fail.

BASH
helm upgrade prod ./hello-web -n prod -f values-production.yaml \
  --set image.tag=does-not-exist --wait --timeout 60s

After a minute Helm reports a timeout and the new revision shows failed in helm history prod -n prod, while kubectl get pods -n prod shows a pod in ImagePullBackOff. The old pods kept serving because the Deployment rolls out gradually and the new pod never became ready. Recover:

BASH
helm rollback prod -n prod --wait
helm history prod -n prod
kubectl get pods -n prod

History shows a new revision labelled as a rollback, and the pods are healthy again. Repeat step 6 with --rollback-on-failure and notice that Helm performs the recovery itself.

Step 7. Package and publish. Bump version in Chart.yaml to 0.1.1 if you changed the chart, then package and push to a local registry as in the packaging section.

Step 8. Clean up.

BASH
helm uninstall staging -n staging
helm uninstall prod -n prod
kubectl delete namespace staging prod helm-lab

If you did all eight steps, you have used every verb Helm has for everyday work: repo, search, show, install, upgrade --install, list, status, get, history, rollback, test, lint, template, package, push, uninstall.

Try it
  1. Run the project end to end.
  2. Without looking, explain why step 5 does not revert the replica count of production.
  3. Explain why step 6 leaves the old pods serving traffic.
step 5 passes the complete values file again, so only message changes; step 6 fails the readiness of the new pod, and Kubernetes keeps the old ones until the new ones are ready.

What you can now do, and what comes next

You can now do real work with Helm:

  • Explain Helm's model: a chart plus values makes a release, and every change is a revision.
  • Install Helm 4, verify it, and point it at the right cluster and namespace.
  • Find charts on Artifact Hub and in repositories or OCI registries, inspect them with show, and install, upgrade, roll back and uninstall them.
  • Configure releases with values files and --set, and predict which value wins.
  • Build your own chart with templates, helpers and a test, and check it with lint, template and --dry-run=server.
  • Package a chart and publish it to a registry.
  • Read Helm's common errors and debug methodically.

Keep these habits from day one: check kubectl config current-context before you act, always pass -n, keep values in files in git, pass the full values on every upgrade, add --wait (and --rollback-on-failure for anything important), and run helm template before you install a chart you wrote.

Mid-level picks up where this stops. It explains how Helm renders and applies a release under the hood, the Helm 4 wait strategies and server-side apply, hooks and dependencies in depth, schemas that validate values, chart testing in CI, and deploying with pipelines. Senior covers running Helm as a platform for a team: security and provenance, supply chain, multi-tenancy, upgrades across Helm and Kubernetes versions, and where Helm stops and GitOps tools take over.

Natural next reading in this catalogue: /student-guides/kubernetes for what Helm actually creates and how to debug it, /student-guides/terraform for creating the clusters Helm deploys into, and /student-guides/docker for building the images your charts run.

Sources