Tools Used in Kubernetes · Helm

Helm

A real service is rarely one object. It's a Deployment, a Service, a ConfigMap, an Ingress, a ServiceAccount, maybe a HorizontalPodAutoscaler — and Kubernetes has no built-in noun for "all of those, together, as one thing." Helm is the package manager that invents that noun: a versioned, parameterized bundle called a chart, installed as a named release that Helm can upgrade, roll back, and remove as a single unit. Under the hood it does nothing exotic — it renders plain YAML from Go templates on your own machine and hands it to the exact same Kubernetes API that the controller pattern and the object model already taught you to reason about. This page covers the chart layout you actually write, how Helm stores what it did, values precedence, the CLI, chart repositories, and the gotchas — above all, how CRDs behave under Helm — that separate a confident production upgrade from a 2 a.m. incident.

☺ Explain it like I'm 10

Think of flat-pack furniture. The box holds every plank and screw — that's the YAML — plus an instruction sheet with blanks on it: pick a color, pick how many shelves. That box is a chart. When you actually build one and put it in your room, the thing standing there is a release, and Helm keeps a little logbook of every version you've ever built, so if shelf 4 wobbles you can say "rebuild version 3" and get the old, working one back exactly. One thing the furniture box can't do: it can't add a whole new room to your house on its own — for that, the building inspector (the CRD rules later on this page) has some very specific opinions about what it will and won't let happen automatically.

🦫Your host for this topic: Benny the Beaver — the builder, the one who turns a pile of YAML into a running workload. Helm just hands Benny a whole chart's worth of manifests to build at once, instead of one at a time.

What Helm is, and the problem it exists to solve

☺ Like you're 10: One team's copy-pasted YAML always ends up different from another team's — Helm gives everyone the same box to build from instead.

A realistic web service is eight to fifteen Kubernetes objects; a monitoring stack is several hundred. Without a packaging layer, teams tend to land in one of two failure modes, usually both: either everyone hand-writes near-identical YAML, so the day a security policy needs runAsNonRoot added everywhere, someone has to go find and patch it across a dozen repos by hand — or someone copies a working folder of manifests once per environment, and within a quarter dev/ and prod/ have quietly drifted in ways nobody planned. A chart is Helm's answer: a directory (or packaged .tgz) of templated manifests plus a values.yaml of defaults, fetchable from a repository, installable, upgradeable, and removable as one unit — which is why almost every piece of third-party Kubernetes software you'll ever install, from an ingress controller to a full observability stack, ships as a Helm chart today.

This page stays close to the mechanics — what a chart is made of, and exactly what Helm does to the Kubernetes API on your behalf. For where Helm sits inside a CI/CD pipeline, the case for standardizing on an internal "paved road" chart, and how it compares to Terraform's and Ansible's neighboring roles, DevOps's own Helm page covers that delivery-chain framing in full rather than repeating it here.

Chart, release, revision — and how Helm talks to the API server

☺ Like you're 10: Helm doesn't run inside your cluster at all — it's a program on your laptop that fills in a form and mails it to the API server, the same way kubectl apply does.

◆ Key idea

Almost every Helm confusion traces back to mixing up three nouns. A chart is the package — inert, versioned, distributable, like a .deb file. A release is one installed instance of a chart, given a name and a namespace; install the same chart three times under three release names and Helm tracks them as three unrelated things. A revision is a numbered snapshot of one release: install is revision 1, each successful helm upgrade produces revision 2, 3, 4 — and helm rollback re-applies an old revision's manifests as a brand-new revision, not a rewind of history.

Helm 3 has no server-side component. Helm 2 ran a cluster-wide, over-privileged daemon called Tiller, trusted by every namespace it touched — removed entirely in Helm 3. The helm binary today is a pure client: it renders templates locally, then submits the result to the Kubernetes API using your kubeconfig and your RBAC. That last part matters more than it sounds — Helm can never do anything to a cluster that the person or CI identity running it isn't already permitted to do directly, and it goes through the same apply machinery the object model already covers: a strategic, three-way merge patch between the last applied state, the new desired state, and whatever's actually live.

Chart + values Chart.yaml values.yaml + -f + --set templates/ Client-side render Go text/template + Sprig runs on your machine no cluster contact yet Kubernetes API server strategic 3-way merge same path as kubectl apply your RBAC, your kubeconfig apply Release Secret helm.sh/release.v1 one per revision, in-namespace record next upgrade's --reuse-values reads this

After a successful install or upgrade, Helm writes a gzipped, base64-encoded copy of the whole release — rendered manifests, the values that produced them, chart metadata, status — as a Kubernetes Secret of type helm.sh/release.v1, named sh.helm.release.v1.<release>.v<revision>, in the release's own namespace. That's the entire database: helm list is a Secret query, helm history lists those Secrets, helm rollback reads an old one and re-applies its manifests. Delete the Secrets by hand and Helm forgets the release ever existed while the workloads it created keep running untouched — the history is genuinely useful and genuinely fragile in exactly the same breath.

The chart you actually write: Chart.yaml, values.yaml, and templates/

☺ Like you're 10: One file names the box, one file lists the knobs, and a folder of instruction sheets fills the blanks in.

A chart is a directory whose layout Helm knows by convention. Every entry has a job — and the two people forget, crds/ and values.schema.json, are exactly the two that cause pain months later.

storefront/
├── Chart.yaml            # name, version, appVersion, dependencies — the only mandatory file
├── values.yaml           # DEFAULT knob settings; doubles as user-facing documentation
├── values.schema.json    # optional JSON Schema — rejects bad values BEFORE rendering
├── templates/
│   ├── deployment.yaml    # Go templates → rendered into plain manifests
│   ├── service.yaml
│   ├── configmap.yaml     # hashed into a pod annotation by the checksum trick below
│   ├── _helpers.tpl       # leading underscore = never rendered as a manifest; named templates
│   ├── NOTES.txt          # templated; printed right after install/upgrade
│   └── tests/
│       └── connection.yaml   # a Pod annotated helm.sh/hook: test, run by `helm test`
├── crds/                  # PLAIN YAML, no templating. Installed first, NEVER upgraded. See gotchas below.
├── charts/                # vendored subcharts, populated by `helm dependency update`
├── Chart.lock             # pinned dependency versions + digest
└── .helmignore            # what to leave out when packaging
# Chart.yaml
apiVersion: v2                  # v2 = the Helm 3+ chart API
name: storefront
description: The storefront web service
type: application                # or "library" for a helpers-only chart
version: 3.1.0                   # the CHART version — bump on every chart change (SemVer)
appVersion: "1.9.2"               # the APP version shipped by default; a string, always quote it

dependencies:
  - name: redis
    version: "~18.4.0"                        # a SemVer RANGE; Chart.lock pins the exact resolved version
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled                  # false in prod → subchart skipped entirely
---
# values.yaml — the defaults, and the user-facing documentation. Comment every knob.
replicaCount: 2
image:
  repository: ghcr.io/acme/storefront
  tag: ""                        # empty → falls back to .Chart.AppVersion
  pullPolicy: IfNotPresent
resources:
  requests: { cpu: 100m, memory: 128Mi }
  limits:   { memory: 512Mi }    # no CPU limit on purpose — avoids throttling surprises
podSecurityContext:
  runAsNonRoot: true
  seccompProfile: { type: RuntimeDefault }
redis:
  enabled: false                 # dev turns this on; prod points at a managed cache instead

Two habits separate templates that age well from templates that quietly rot: pipe structured values through toYaml | nindent instead of hand-indenting them, and put every name and label behind a named template in _helpers.tpl so the naming convention lives in exactly one place.

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

{{- define "storefront.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
---
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "storefront.fullname" . }}
  labels: {{- include "storefront.labels" . | nindent 4 }}   # include+nindent, never `template`
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app.kubernetes.io/name: {{ .Chart.Name }}
      app.kubernetes.io/instance: {{ .Release.Name }}
  template:
    metadata:
      annotations:
        # roll the pods whenever the ConfigMap content changes — the classic checksum trick
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
      labels: {{- include "storefront.labels" . | nindent 8 }}
    spec:
      securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: storefront
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          resources: {{- toYaml .Values.resources | nindent 12 }}

A small set of built-in objects is what makes that render possible — every template can see them, filled in from the merged values and the chart's own metadata:

ObjectHoldsReach for it when
.ValuesThe merged values — chart defaults plus every -f and --setAlmost always; it's the user's knob panel
.Release.Name, .Namespace, .Revision, .IsInstall, .IsUpgradeNaming resources, or doing something only on first install
.ChartThe parsed Chart.yaml — notably .Name, .Version, .AppVersionDefaulting the image tag and standard labels
.Capabilities.KubeVersion and .APIVersions.Has "…"One chart has to work across more than one cluster version
.FilesNon-template files inside the chart (.Get, .Glob)Dropping a real config file into a ConfigMap verbatim

Values precedence, and the real difference between helm install and helm upgrade --install

☺ Like you're 10: Later value files beat earlier ones, and a command-line flag beats every file — but Helm forgets your flags the moment the next upgrade runs.

Charts stay reusable across dev, staging, and prod because values layer, and the order is worth memorizing rather than guessing under pressure: --set / --set-string / --set-json on the command line beats every -f file (later files beating earlier ones), which beats a parent chart's values for its subcharts, which beats the chart's own values.yaml. Most teams commit one values file per environment — values.yaml for author defaults, values-prod.yaml holding only the handful of keys that actually differ — and pass the right one from CI, keeping --set for the one thing that genuinely changes every run, usually the image tag.

⚠ Values are not remembered between upgrades

The single most common Helm surprise in production. helm upgrade does not remember what you passed last time — it re-renders from chart defaults plus whatever you supply this run, nothing else. Ran --set replicaCount=6 last week and forgot to pass it again this week? Production quietly reverts to the chart's default of 2. The fix is either --reuse-values (merge the previous release's supplied values with anything new) or, better, keep the real value in a committed values-prod.yaml so "what is prod actually set to" has a permanent, reviewable answer instead of living in someone's shell history.

Now the distinction the brief for this page asks about directly. helm install creates a brand-new release and fails outright if a release by that name already exists in that namespace — it assumes you know the state of the world. helm upgrade --install is the idempotent version: install if the release is absent, upgrade it if it's already there. That single flag is why almost every real pipeline runs helm upgrade --install and essentially nobody scripts a bare helm install — a CI job that redeploys the same release name on every merge shouldn't need to know in advance whether this is attempt one or attempt fifty. Reach for plain helm install only in a context where "this must not already exist" is itself the thing you're asserting, such as a guarded first-time bootstrap step.

Where charts come from: classic repositories vs. OCI registries

☺ Like you're 10: A chart repo is just a folder of boxes with a catalog card taped to the front — or, increasingly, the same shelf your container images already live on.

Helm's original distribution model is a plain HTTP endpoint serving an index.yaml catalog alongside packaged .tgz charts — helm repo add registers one, helm repo update refreshes the local cache of that catalog, and helm search repo queries it offline. That model still runs most public chart repositories today, from ingress-nginx's own repo to Bitnami's catalog. The newer, increasingly default option is an OCI registry — the same protocol that stores container images — which needs no separate repo add step at all; you reference a chart directly by its oci:// reference, and the same registry (GHCR, ECR, Harbor, a private registry) that already hosts your images hosts your charts too, with the same auth model and the same retention and scanning tooling.

# classic index-based repo
$ helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
$ helm repo update                                 # refresh the local catalog — skip this and versions go stale
$ helm search repo ingress-nginx --versions
$ helm show values ingress-nginx/ingress-nginx > /tmp/defaults.yaml

# OCI registry — no repo add needed, reference the chart directly
$ helm push ./storefront-3.1.0.tgz oci://ghcr.io/acme/charts
$ helm install storefront oci://ghcr.io/acme/charts/storefront --version 3.1.0
$ helm pull oci://ghcr.io/acme/charts/storefront --version 3.1.0 --untar

Day-to-day commands

☺ Like you're 10: A handful of commands cover almost everything: check the box before opening it, render it, ship it, undo it.

$ helm lint ./storefront -f values-prod.yaml       # schema + chart structure checks, no cluster contact
$ helm dependency update ./storefront              # resolve deps into charts/, write Chart.lock

# RENDER LOCALLY — the single most useful command. No cluster contact, no release created.
$ helm template storefront ./storefront -f values-prod.yaml | less
$ helm template storefront ./storefront -f values-prod.yaml | kubectl apply --dry-run=server -f -

# the idempotent workhorse — use this, not a bare `helm install`
$ helm upgrade --install storefront ./storefront \
    --namespace storefront --create-namespace \
    -f values.yaml -f values-prod.yaml \
    --set image.tag=1.9.2 \
    --atomic --timeout 5m                           # --atomic auto-rolls-back on failure or timeout

$ helm list -A                                     # every release, every namespace
$ helm status storefront -n storefront             # current revision, status, NOTES.txt
$ helm history storefront -n storefront            # every revision, and why it exists
$ helm get values storefront -n storefront -a       # ALL computed values, including chart defaults
$ helm get manifest storefront -n storefront        # exactly what Helm applied — the ground truth

$ helm rollback storefront 6 -n storefront          # re-apply revision 6's manifests AS A NEW REVISION
$ helm uninstall storefront -n storefront           # deletes resources; NOT CRDs, NOT PVCs
✎ Try it

On a throwaway kind or minikube cluster: helm create demo, then run helm template demo ./demo and read every rendered line against the template line that produced it. Install with helm upgrade --install, then run kubectl get secret -l owner=helm and look — your release lives there as data, no magic involved. Upgrade with --set replicaCount=4, upgrade again without that flag, and watch the count silently fall back to 2. Finish with helm history demo and helm rollback demo 1. If kubectl's own muscle memory feels shaky going in, this course's kubectl tool guide and fluency baseline are the right warm-up first.

Gotchas: the CRD lifecycle trap, and other ways a release surprises you

☺ Like you're 10: Helm is cutting and pasting text, so whitespace matters enormously — and one specific folder, once installed, Helm will never touch again.

The CRD lifecycle is the one to memorize before anything else. Files in a chart's crds/ directory install before anything in templates/, are never templated — no {{ }} at all — and are, by deliberate design, never upgraded and never deleted by Helm, on any command, at any version. A chart that adds a field to its CRD in version 2 will happily render v2 custom resources against a still-installed v1 CRD, and the API server silently strips or rejects the new field, exactly the same schema-enforcement behavior CRDs & the operator pattern covers in full for CRDs generally. helm uninstall leaves the CRDs in the cluster forever too — by design, since deleting a CRD deletes every custom resource of that Kind cluster-wide, including ones other releases might depend on.

chart v1 · install chart v2 · upgrade templates/ Deployment, ConfigMap templates/ updated normally always upgraded crds/ CRD v1 — installed once crds/ still CRD v1, untouched never touched again A custom resource using v2's new field is silently rejected or stripped — the live CRD schema was never updated.

The operational fix is manual and worth writing into a runbook: apply the new CRD yourself with kubectl apply -f from the new chart version before running the Helm upgrade, and actually read a chart's upgrade notes when a major version bumps. Some chart authors sidestep the restriction entirely by putting their CRD manifest in templates/ instead of crds/, which does let it template and upgrade normally — but that trades away Helm's safety rail: two releases of the same chart now fight over one cluster-scoped CRD, and an ordinary helm uninstall can delete the CRD and every custom resource of that Kind across the whole cluster, not just the ones this release created. Neither choice is free; know which one a chart made before you rely on it. Flux's HelmRelease controller renders through the same underlying Helm engine and inherits this exact constraint — a GitOps wrapper changes who runs helm upgrade, not what Helm is willing to do to a CRD.

A handful of smaller traps round out the list. The render engine has no idea it's producing YAML — it concatenates text — so indent N (pad every line) and nindent N (newline first, then pad — almost always what you want right after a key:) getting swapped silently reparents a whole block under the wrong key. An interrupted upgrade — a killed CI job, a network blip — leaves a release stuck in pending-upgrade, and every later attempt fails with "another operation is in progress"; there's no lock file to delete, the state lives inside the release Secret itself, and helm rollback to the last good revision usually clears it. Helm also keeps only the last ten revisions by default (--history-max), so a rollback target can quietly age out of history, and because each revision is a full rendered manifest stored as a Secret, a genuinely large chart can bump into etcd's roughly 1 MiB object size limit. And template functions like randAlphaNum, now, or lookup render differently on every single pass — harmless under a one-shot helm upgrade, but permanent, meaningless drift under a reconciler like the one covered in GitOps on Kubernetes, which keeps "fixing" a diff that was never really wrong.

Helm vs. the alternatives on this course's toolchain

☺ Like you're 10: Other tools also turn settings into YAML — they just trade power for safety in different amounts, and Kubernetes is happy to take any of them.

OptionModelBest whenCosts you
HelmText-templated, versioned, distributable package with a tracked release historyYou must distribute a chart to other teams or consume third-party software; you want one artifact across many environments, plus rollbackString templating blind to YAML structure; a second in-cluster state store (release Secrets) sitting beside the cluster's own desired state
KustomizeTemplate-free structural patches over a shared baseConfig you own and change often, where readable, always-valid YAML at every step matters more than distributionNo packaging and no versioned artifact of its own; deep overlay chains get hard to trace
Raw kubectl applyHand-written YAML, applied directlyA handful of objects, one environment, genuinely low stakesDuplication and drift arrive the moment a second environment shows up
GitOps reconcilers (Argo CD, Flux)Continuously reconcile a cluster to a Git repo's desired state — often rendering a Helm chart internally to get thereDrift should be caught and corrected automatically, not just applied onceA different layer, not a substitute — see GitOps on Kubernetes for the full pattern

The rule most mature platforms settle on: reach for Helm when an artifact has to travel — across teams, across clusters, across an organization — and a version number should be the unit of change; reach for plain, patched manifests when config is yours alone, changes constantly, and benefits from staying boring, readable YAML. Helm isn't a named CKA exam domain, but recent CKAD curriculum versions have specifically expected hands-on chart install/upgrade skill — confirm the current weighting on the CNCF's own curriculum page before you plan study time around it, from this course's CKAD blueprint. Helm itself is a CNCF graduated project, alongside Kubernetes; the wider ecosystem it sits in is mapped on this course's own CNCF project landscape page.

🎬 At the Pod Squad
🦫

Benny the Beaver: Chart's rendered, upgrade's applied — every object matches the new version. Except this CustomResourceDefinition field I added isn't showing up on the live resource.

🦊

Foxy: I bumped the chart version, the CRD in crds/ clearly has the new field now. Why isn't it there?

🦫

Benny: Because Helm never touched it, Foxy. Anything in crds/ installs once and I leave it alone forever after — upgrade or not. The cluster's still running the CRD from install day.

👺

Gizmo: Easy fix — helm uninstall and reinstall clean. Two seconds, brand-new CRD, done. 🤑

🐢

Timmy the Turtle: Absolutely not — deleting that CRD deletes every custom resource of that Kind in the cluster, including ones other releases still depend on. kubectl apply -f the new CRD by hand, verify it, then upgrade. I'm not promoting the shortcut that takes out someone else's data to fix your field.

🤖

Recon the Robot: And once that CRD's caught up, I'll go back to reconciling the custom resources against it like any other Kind — Helm handing off doesn't change what my loop does next.

🐘

Ellie the Elephant: Logging it either way — which chart version, which CRD version, who applied the fix by hand. Next time someone asks "has this happened before," the answer's in my record.

🐢 Timmy's checkpoint

1. Distinguish a chart, a release, and a revision in one sentence each. 2. Where does Helm 3 store release state, and what in-cluster component did it replace? 3. What's the practical difference between helm install and helm upgrade --install, and which one does almost every real pipeline script? 4. Put these in precedence order: chart values.yaml, --set, -f values-prod.yaml. 5. A chart ships a CRD in crds/ and version 2 adds a field to it. What does a plain helm upgrade do about that field, and what's the manual fix? 6. Name one real cost of a chart author instead putting that same CRD inside templates/.

Check your answers
  1. A chart is the versioned, distributable package. A release is one named, installed instance of a chart in a namespace. A revision is a numbered snapshot of that release — install is revision 1, each successful upgrade adds one, and a rollback re-applies an old revision's manifests as a new revision rather than rewinding.
  2. In a Kubernetes Secret (type helm.sh/release.v1) in the release's own namespace, one per revision. It replaced Helm 2's in-cluster, over-privileged Tiller server, which Helm 3 removed entirely.
  3. helm install creates a new release and fails if one by that name already exists. helm upgrade --install installs if absent and upgrades if present — idempotent, create-or-update. Almost every real pipeline scripts helm upgrade --install, since a redeploy shouldn't need to know in advance whether the release already exists.
  4. Lowest to highest: chart values.yaml-f values-prod.yaml (later files beat earlier ones) → --set.
  5. Nothing. Files in crds/ install only if absent and are never upgraded or deleted by Helm, regardless of chart version. The fix is manual: kubectl apply -f the new chart's CRD yourself before running the Helm upgrade.
  6. Two releases of the same chart can fight over one cluster-scoped CRD, and an ordinary helm uninstall can delete the CRD — and every custom resource of that Kind cluster-wide, not just this release's — instead of leaving it alone the way crds/ does.