# Helm, From Zero

> The package manager for Kubernetes: charts template your manifests, values parameterize them per environment, and releases are versioned and rollback-able.


---

# Helm, From Zero

You have a Deployment, a Service, a ConfigMap, and an Ingress. Now you need them in dev, staging, and prod - same shape, different image tags, different replica counts, different hostnames. So you copy the YAML three times and pray you remember to change every value in every copy. You won't. Something drifts, and the bug only shows up in prod.

Helm is the way out. A chart is your manifests with the per-environment parts pulled out into a `values.yaml` file. One template, many environments. And every deploy becomes a versioned *release* you can roll back with a single command. This guide gets you from "what even is a chart" to shipping and rolling back with confidence - and knowing when Helm is the wrong tool.

## How to read this

Go in order. Phase 1 builds the mental model: what a chart actually is and the problem it solves, so the commands later feel obvious instead of magic. Phase 2 is the everyday loop - `install`, `upgrade`, `rollback`, values, releases - the stuff you'll run every day. Phase 3 is where it bites: templating gotchas, the `--dry-run` habit that saves you, and the clear-eyed call on Helm vs Kustomize vs plain manifests.

If Kubernetes itself is still fuzzy, read [Kubernetes Without the Hype](/guides/kubernetes-without-the-hype) first - Helm only makes sense once you know what a Deployment and a Service are.

## The phases

1. [The Mental Model: A Chart Is Templated Manifests](01-the-mental-model.md) - what a chart is, why values exist, what a release is.
2. [The Everyday Loop: Install, Upgrade, Rollback](02-the-everyday-loop.md) - the commands you run daily and how releases get tracked.
3. [Where It Bites: Templating Gotchas and When Not to Use Helm](03-where-it-bites.md) - whitespace, dry-runs, and Helm vs Kustomize vs raw YAML.


---

# The Mental Model: A Chart Is Templated Manifests

Let's start with the pain, because that's where Helm earns its keep. You've got a working app on Kubernetes - a Deployment, a Service, maybe an Ingress. It runs in dev. Now you need it in staging and prod too.

The manifests are nearly identical across environments. What changes is small: the image tag, the number of replicas, the hostname, a memory limit. So the obvious move is to copy the YAML into three folders and hand-edit the differences.

That works for about a week. Then you bump the image tag in dev and prod but forget staging. Or you add an environment variable to one copy and not the others. The three copies drift, and the worst part is you don't find out until something behaves differently in prod than it did in dev. You've turned "deploy my app" into "keep three near-identical piles of YAML in sync by hand," which is a job no human does reliably.

## The one idea: pull the differences into a values file

Here's the whole mental model. A Helm **chart** is your Kubernetes manifests with the parts-that-change pulled out into placeholders, plus a `values.yaml` file that fills those placeholders in.

Instead of three copies of a Deployment, you have **one template** and **one set of values per environment**. The template never changes between environments. Only the values do.

Think of it the way you already think of a function. The template is the function body. The values are the arguments. You don't copy-paste a function for every caller - you call it with different arguments. Helm brings that same idea to YAML.

Here's a normal Kubernetes Deployment, the kind you'd write by hand:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: web
          image: myapp:1.4.0
```

*What just happened:* That's a fine manifest, but `replicas: 2` and `image: myapp:1.4.0` are baked in. To run it in prod with 5 replicas and a different tag, you'd copy the whole file and edit two lines - the exact drift trap we're trying to escape.

Now here's the same thing as a chart template:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-web
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: web
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
```

*What just happened:* The hard-coded numbers became `{{ .Values.something }}` placeholders. The `{{ }}` is Helm's template syntax - at install time, Helm replaces each one with a value. `.Values` reads from your `values.yaml`; `.Release.Name` is the name you give this particular deployment. The shape is identical to the plain manifest; the changeable parts are now inputs.

And the `values.yaml` that feeds it:

```yaml
replicaCount: 2
image:
  repository: myapp
  tag: "1.4.0"
```

*What just happened:* This is the defaults file. The keys here line up with the `.Values.*` references in the template. For prod, you'd keep the same template and supply a different values file with `replicaCount: 5` and `tag: "1.4.1"`. Same body, different arguments.

> The single biggest unlock with Helm is this split: **structure lives in templates, configuration lives in values.** Once a piece of YAML differs between environments, it belongs in `values.yaml`, not hard-coded in the template.

## What's actually in a chart

A chart is a directory with a specific layout. You don't have to memorize it - `helm create` scaffolds it for you - but knowing the four pieces that matter makes everything else click:

```text
mychart/
  Chart.yaml          # name, version, description - the chart's identity card
  values.yaml         # default values that fill the templates
  templates/          # the templated manifests
    deployment.yaml
    service.yaml
  charts/             # other charts this one depends on (subcharts)
```

*What just happened:* `Chart.yaml` is metadata about the chart itself (its name and version). `values.yaml` holds the defaults. `templates/` is where your manifests-with-placeholders live - Helm renders every file in here. `charts/` holds dependencies, which you can ignore until you actually need one. That's the whole structure.

One naming distinction that trips people up: `Chart.yaml` has a `version` (the version of the *chart*) and an `appVersion` (the version of the *app inside it*). They're different things. The chart version goes up when you change the templates; the app version tracks the software you're deploying. Keeping them straight saves confusion later.

## A release is a versioned, named install

Here's the second big idea, and it's the one that makes Helm feel safe.

When you `helm install` a chart, you give that installation a name - say `web-prod`. Helm renders the templates with your values, sends the resulting manifests to Kubernetes, and **records what it sent** as a *release*. A release is a named, versioned snapshot of "this chart, with these values, applied at this time."

When you change a value and run `helm upgrade web-prod`, Helm renders again and records release **revision 2**. The old revision doesn't vanish - Helm remembers it. So when revision 2 turns out to be broken, `helm rollback web-prod 1` puts you back exactly where you were.

```text
revision 1  →  myapp:1.4.0, 2 replicas   (helm install)
revision 2  →  myapp:1.4.1, 2 replicas   (helm upgrade)   ← broken!
revision 3  →  myapp:1.4.0, 2 replicas   (helm rollback web-prod 1)
```

*What just happened:* Each deploy is a numbered revision, and Helm keeps the history. A rollback isn't a frantic re-edit of YAML under pressure - it's one command that re-applies a known-good past revision. This is the difference between "we deployed a bad version" being a five-minute fix versus a midnight incident.

That's the entire foundation. A **chart** is templated manifests plus default values. A **values file** parameterizes the chart per environment. A **release** is a named, versioned install you can upgrade and roll back. Everything in the next phase is the commands that drive these three ideas.

> **For builders:** the values-vs-template split is the same instinct as keeping config out of code and in environment variables. You wouldn't hard-code a database URL in your source; don't hard-code a replica count in a manifest you reuse across environments.

```quiz
[
  {
    "q": "In Helm's mental model, what belongs in values.yaml versus in a template?",
    "choices": [
      "Everything goes in values.yaml; templates are optional",
      "Structure goes in templates; the parts that change per environment go in values",
      "Templates hold the values; values.yaml holds the structure",
      "Both must contain identical copies of every manifest"
    ],
    "answer": 1,
    "explain": "Templates hold the fixed shape of your manifests; values hold the per-environment differences like image tag and replica count."
  },
  {
    "q": "What is a Helm 'release'?",
    "choices": [
      "A published version of a chart on a public repository",
      "The act of running 'helm create'",
      "A named, versioned record of a chart installed with specific values",
      "Another word for the values.yaml file"
    ],
    "answer": 2,
    "explain": "A release is a named install whose revisions Helm tracks, which is exactly what makes rollback possible."
  },
  {
    "q": "Why does copying manifests per environment cause problems?",
    "choices": [
      "Kubernetes rejects duplicate YAML files",
      "The copies drift out of sync because changes don't propagate to every copy",
      "Helm refuses to install more than one copy",
      "YAML cannot be copied between folders"
    ],
    "answer": 1,
    "explain": "Hand-edited copies inevitably drift; one template plus per-environment values keeps the shared structure in a single place."
  }
]
```


---

# The Everyday Loop: Install, Upgrade, Rollback

You understand the three ideas now: chart, values, release. This phase is the muscle memory - the handful of commands you'll actually run, in the order you'll run them. None of them is complicated. The skill is knowing which one to reach for and what it does to your release history.

## Scaffold a chart

You rarely write a chart from a blank file. `helm create` gives you a working starter chart you trim down:

```console
$ helm create myapp
Creating myapp

$ ls myapp
Chart.yaml  charts  templates  values.yaml
```

*What just happened:* Helm generated the standard chart layout from the last phase, pre-filled with a sample Deployment, Service, and a sensible `values.yaml`. The generated chart is more than most apps need - treat it as a starting point to delete from, not a sacred template. Open `templates/deployment.yaml` and you'll see the same `{{ .Values.* }}` placeholders you already understand.

## Render before you ship: helm template

Before sending anything to a cluster, see what Helm will actually produce. `helm template` renders the chart locally and prints the final YAML - no cluster involved:

```console
$ helm template myapp ./myapp
---
# Source: myapp/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: myapp
...
---
# Source: myapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 1
...
```

*What just happened:* Helm filled in every placeholder using the defaults in `values.yaml` and printed the manifests it would send to Kubernetes - the placeholders are gone, replaced by real values. This is your read-before-you-commit step. If the output looks wrong here, it'll be wrong in the cluster. Get in the habit of running this whenever you change a template.

## Install: create a release

`helm install` renders the chart and applies it to your cluster, recording the result as a named release:

```console
$ helm install web ./myapp
NAME: web
LAST DEPLOYED: Mon Jun 30 09:14:02 2026
NAMESPACE: default
STATUS: deployed
REVISION: 1
```

*What just happened:* `web` is the **release name** you chose - every command from here on refers to this release by that name. `REVISION: 1` is the key line: this is the first version of the release. Helm rendered the templates with the default values, sent the manifests to the cluster, and stored revision 1 in its history.

To install the same chart with different settings, point Helm at a values file or override individual keys on the command line:

```console
$ helm install web-prod ./myapp -f values-prod.yaml --set image.tag=1.4.1
```

*What just happened:* `-f values-prod.yaml` layers a prod-specific values file on top of the chart's defaults; `--set image.tag=1.4.1` overrides one key inline. This is the per-environment story in action: **same chart, different values, different release name.** Values from `-f` and `--set` win over the chart's `values.yaml`, and `--set` wins over `-f` - later sources override earlier ones.

> Keep one values file per environment (`values-dev.yaml`, `values-prod.yaml`) checked into git. Reserve `--set` for one-off overrides and CI-injected values like the image tag. A `--set` that should have been permanent is a value you'll forget you applied.

## See what's running

Two commands answer "what's deployed and is it healthy":

```console
$ helm list
NAME      NAMESPACE  REVISION  STATUS    CHART        APP VERSION
web       default    1         deployed  myapp-0.1.0  1.16.0
web-prod  default    1         deployed  myapp-0.1.0  1.4.1

$ helm status web
NAME: web
STATUS: deployed
REVISION: 1
```

*What just happened:* `helm list` shows every release in the namespace with its current revision and status - your inventory of what Helm manages. `helm status web` zooms into one release. If a release ever shows a status other than `deployed` (like `failed` or `pending-upgrade`), that's your signal something went sideways during the last operation.

## Upgrade: change a release

When you ship a new image tag or tweak a value, you `helm upgrade` the existing release - you do **not** install again:

```console
$ helm upgrade web ./myapp --set image.tag=1.4.1
NAME: web
STATUS: deployed
REVISION: 2
```

*What just happened:* Helm re-rendered the chart with the new tag and applied the diff to the cluster. The crucial detail is `REVISION: 2` - Helm bumped the revision and kept revision 1 in history. Upgrade is the workhorse: nearly every deploy after the first is an upgrade.

A safer habit is `helm upgrade --install` (often written `helm upgrade -i`):

```console
$ helm upgrade --install web ./myapp --set image.tag=1.4.1
```

*What just happened:* This upgrades the release if it exists, or installs it if it doesn't. It's the standard command in CI pipelines because it works whether or not the release is already there - no need to branch on "first deploy versus later deploy." One command for both cases.

## Inspect history and roll back

Helm keeps the revision history, and that history is what makes rollback trivial:

```console
$ helm history web
REVISION  STATUS      CHART        APP VERSION  DESCRIPTION
1         superseded  myapp-0.1.0  1.16.0       Install complete
2         deployed    myapp-0.1.0  1.4.1        Upgrade complete
```

*What just happened:* `superseded` means an older revision that's been replaced; `deployed` is the live one. This is the audit trail - you can see exactly what each revision was and when it landed.

Now suppose revision 2 is broken. You don't dig through git or hand-edit YAML under pressure. You roll back:

```console
$ helm rollback web 1
Rollback was a success! Happy Helming!

$ helm history web
REVISION  STATUS      CHART        APP VERSION  DESCRIPTION
1         superseded  myapp-0.1.0  1.16.0       Install complete
2         superseded  myapp-0.1.0  1.4.1        Upgrade complete
3         deployed    myapp-0.1.0  1.16.0       Rollback to 1
```

*What just happened:* `helm rollback web 1` re-applied the exact state of revision 1 - and recorded it as a **new** revision 3. Notice rollback doesn't delete history or rewind the counter; it moves forward to a state identical to a past one. Run `helm rollback web` with no number and it goes to the immediately previous revision. This is the payoff of the release model: recovery is one command, and the trail stays intact.

## Tear down

When you're done with a release, remove it and everything it created:

```console
$ helm uninstall web
release "web" uninstalled
```

*What just happened:* Helm deleted every resource it created for the `web` release - the Deployment, Service, and so on - in one shot. No hunting down individual objects with `kubectl delete`. Because Helm tracked exactly what it installed, it knows exactly what to remove.

That's the full daily loop: `create` to scaffold, `template` to preview, `install` for the first deploy, `upgrade` (or `upgrade --install`) for every change, `history` and `rollback` when something breaks, `uninstall` to clean up. Six commands cover almost everything you'll do.

> **In the wild:** most teams never run `helm install` by hand after the first day. CI runs `helm upgrade --install` on every merge, with the image tag injected via `--set`. Humans mostly run `helm list`, `helm history`, and the occasional `helm rollback` when a deploy goes wrong.

```quiz
[
  {
    "q": "You changed the image tag and want to apply it to an already-running release. Which command?",
    "choices": [
      "helm install web ./myapp",
      "helm upgrade web ./myapp",
      "helm create web",
      "helm template web ./myapp"
    ],
    "answer": 1,
    "explain": "Existing releases are changed with helm upgrade, which bumps the revision and keeps history. Re-running install would error because the release already exists."
  },
  {
    "q": "What does 'helm rollback web 1' do to the revision history?",
    "choices": [
      "Deletes revisions 2 and 3 and rewinds to revision 1",
      "Edits revision 1 in place",
      "Re-applies revision 1's state and records it as a new revision",
      "Uninstalls the release entirely"
    ],
    "answer": 2,
    "explain": "Rollback moves forward: it creates a new revision identical to the target, preserving the full history rather than rewinding the counter."
  },
  {
    "q": "Why is 'helm upgrade --install' the common choice in CI pipelines?",
    "choices": [
      "It skips the cluster and only renders locally",
      "It installs if the release is absent and upgrades if it exists, so one command handles both",
      "It deletes the release before reinstalling",
      "It is the only command that accepts --set"
    ],
    "answer": 1,
    "explain": "The flag makes the command idempotent across first-deploy and later-deploy, so the pipeline needs no special-casing."
  }
]
```


---

# Where It Bites: Templating Gotchas and When Not to Use Helm

The commands are the easy part. What actually costs people hours is the templating layer - Helm renders text, and text-templating YAML has sharp edges. This phase is the stuff nobody tells you up front: the gotchas, the habits that catch them early, and the straight answer to "should I even be using Helm here?"

## Helm templates text, not YAML - and that's the root of most pain

Here's the mental shift that explains nearly every Helm headache. Helm's template engine doesn't understand YAML structure. It does **text substitution** and then hands the resulting string to a YAML parser. So indentation, quoting, and whitespace are your problem, not Helm's.

The most common bite is indentation. Say you want to inject a block of labels:

```yaml
metadata:
  labels:
    {{ .Values.labels }}
```

*What just happened:* This looks reasonable and breaks immediately. `.Values.labels` is a map, and dropping it in raw produces something like `map[app:web tier:frontend]` - not valid YAML. Helm renders text; it won't magically format a map into indented YAML keys for you.

The fix uses two built-in functions, `toYaml` and `nindent`:

```yaml
metadata:
  labels:
    {{- toYaml .Values.labels | nindent 4 }}
```

*What just happened:* `toYaml` converts the map into proper YAML text, and `nindent 4` adds a leading newline and indents every line by 4 spaces so it nests correctly under `labels:`. The `{{-` trims the whitespace before the tag so you don't get a stray blank line. This `toYaml | nindent N` pattern is how you inject any map or list - memorize it, because you'll use it constantly.

## Whitespace control: the dash that saves your YAML

Template tags leave behind whitespace and blank lines that quietly corrupt your output. The `-` inside a tag trims it:

```yaml
spec:
  {{- if .Values.enableMetrics }}
  metricsPort: 9090
  {{- end }}
```

*What just happened:* `{{- if ... }}` trims the whitespace and newline *before* the tag, so when the condition is false you don't get an empty line where the block used to be. Without the dashes, conditional blocks leave ragged blank lines that sometimes parse and sometimes don't. The rule of thumb: put `{{-` on control-flow tags (`if`, `range`, `end`) to keep rendered YAML clean. When indentation looks right but YAML still won't parse, suspect whitespace first.

## Always dry-run before you ship

You already met `helm template` for local rendering. Its cluster-aware cousin is `--dry-run`, and it's the single most valuable habit in Helm:

```console
$ helm upgrade --install web ./myapp --set image.tag=1.4.1 --dry-run
NAME: web
STATUS: pending-upgrade
REVISION: 2
HOOKS:
...
MANIFEST:
---
# Source: myapp/templates/deployment.yaml
...
```

*What just happened:* `--dry-run` renders the chart and runs it through the cluster's validation, but applies **nothing**. You see the exact manifests that would be sent and catch errors - a broken template, an invalid value, a typo'd field - before they touch a running system. Pair it with `helm template` for fast local checks and `--dry-run` for the real pre-flight against the cluster.

A close companion is `helm lint`, which checks the chart for structural problems:

```console
$ helm lint ./myapp
==> Linting ./myapp
1 chart(s) linted, 0 chart(s) failed
```

*What just happened:* `helm lint` catches missing required fields, malformed `Chart.yaml`, and common chart mistakes without rendering against any cluster. It's cheap to run in CI as a first gate - if lint fails, there's no point trying to deploy.

## The values-precedence gotcha

When the same key is set in multiple places, Helm has a clear precedence order, and getting it wrong leads to "why isn't my override working?" The order, from lowest to highest priority:

```text
chart's values.yaml   (lowest - the defaults)
   ↓ overridden by
-f myvalues.yaml      (your environment file)
   ↓ overridden by
--set key=value       (highest - inline overrides win)
```

*What just happened:* Higher-priority sources override lower ones for any shared key. So a `--set image.tag=1.4.1` will beat whatever `image.tag` is in your `-f values-prod.yaml`, which beats the chart default. When an override seems ignored, it's almost always because something higher in this order is also setting that key. Multiple `-f` files apply left to right, with the rightmost winning.

## When Helm is the wrong tool

Helm is not free. The templating layer adds a whole syntax between you and your YAML, and for simple cases that's overhead you don't need. Here's the clear decision guide:

```text
Plain manifests (kubectl apply)
  → one app, one environment, rarely changes. No parameterization needed.

Kustomize (kubectl apply -k)
  → a few environments that differ by patches/overlays, and you want to
    keep editing plain YAML with no templating language to learn.

Helm
  → real parameterization (loops, conditionals), you need release tracking
    and rollback, or you're packaging an app for others to install.
```

*What just happened:* The deciding questions are: do you need templating logic (conditionals, loops), and do you need versioned releases with rollback? If both are "no," Helm's syntax is pure cost - reach for Kustomize or plain manifests. If you need an app to be reusable and configurable by people who didn't write it, Helm's packaging and release model is exactly the right fit.

The key contrast with Kustomize: **Kustomize patches plain YAML with overlays - no templating language, the files stay valid Kubernetes manifests.** Helm templates YAML with a real language - more power, more rope. Neither is "better"; they solve different shapes of problem. Many teams use both: Helm for third-party apps they install (databases, ingress controllers), Kustomize for their own services. They are not mutually exclusive.

> **In the wild:** the strongest case for Helm is installing other people's software. When you pull a chart for a database or monitoring stack, you get a tested, parameterized package and a single command to upgrade or roll it back. For your own handful of services, Kustomize overlays are often the lower-friction choice. Match the tool to whether you're *packaging for others* or *configuring for yourself*.

If you want the broader picture of how these manifests fit into a cluster, [Kubernetes Without the Hype](/guides/kubernetes-without-the-hype) covers the objects Helm is templating, and [Docker Without the Magic](/guides/docker-without-the-magic) covers the images those Deployments actually run.

```quiz
[
  {
    "q": "Why does injecting a map directly with {{ .Values.labels }} usually break the YAML?",
    "choices": [
      "Helm forbids maps in values.yaml",
      "Helm does text substitution, so a raw map renders as an invalid string instead of formatted YAML",
      "Maps must always be passed with --set",
      "The labels key is reserved by Kubernetes"
    ],
    "answer": 1,
    "explain": "Helm renders text and lets a YAML parser read the result, so maps and lists need toYaml plus nindent to become valid indented YAML."
  },
  {
    "q": "If the same key is set in the chart's values.yaml, a -f file, and via --set, which value wins?",
    "choices": [
      "The chart's values.yaml",
      "The -f file",
      "The --set value",
      "Helm errors on the conflict"
    ],
    "answer": 2,
    "explain": "Precedence runs values.yaml < -f < --set, so an inline --set overrides both the file and the chart default."
  },
  {
    "q": "When is plain Kustomize a better fit than Helm?",
    "choices": [
      "When you need loops and conditionals in your manifests",
      "When you want versioned releases with one-command rollback",
      "When environments differ by simple patches and you'd rather not learn a templating language",
      "When packaging an app for strangers to install"
    ],
    "answer": 2,
    "explain": "Kustomize patches plain YAML with overlays and adds no templating language; it shines for a few environments differing by patches. Helm earns its cost when you need real templating logic or release tracking."
  }
]
```
