Tools Used in DevOps · Helm

Helm

Kubernetes has no built-in idea of "an application" — a real service is a Deployment, a Service, a ConfigMap, an Ingress, a ServiceAccount, maybe a PodDisruptionBudget and a HorizontalPodAutoscaler, and Kubernetes is perfectly happy to let a team have some of those and not others. Helm is the package manager that invents the missing noun: a versioned, parameterized bundle called a chart that packages all of those objects as templates plus a file of default settings, so a team can install, upgrade, and roll back that whole bundle as one unit instead of hand-editing a folder of YAML per environment. It is the tool most platform teams reach for the moment containers & orchestration stops being one Deployment and starts being a real, multi-object, multi-environment application.

☺ 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 your 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 notebook of every version you've ever built, so if the new shelf wobbles you can say "rebuild version 3" and get the old one back exactly. The best part: anyone can hand you the box. You don't need to know how the furniture works to put it together correctly.

🐙Your host for this topic: Olly the Octopus — eight arms, one job: keep every object a chart produces moving together instead of drifting apart, the exact skill containers & orchestration hangs on Olly, now pointed at a chart instead of a bare Deployment.

What Helm is and the problem it solves

☺ Like you're 10: It puts a whole app's YAML in one labeled box with knobs on the front, so installing it means turning knobs instead of writing YAML by hand.

A realistic web service is eight to fifteen Kubernetes objects. A monitoring stack is several hundred. Without a packaging layer, an organization ends up in one of two failure modes — usually both at once. Either every team hand-writes near-identical YAML, so the day security mandates runAsNonRoot on every container, someone has to go find and fix it in nine different repos by hand; or someone copies a working folder of manifests once per environment, and within a quarter dev/ and prod/ have quietly diverged in ways nobody planned and nobody can fully account for. Helm answers both problems the same way a package manager answers them for an operating system: one templated source of truth, many parameterized installs, and a versioned artifact that other teams consume rather than fork.

A chart is that artifact — a directory (or a packaged .tgz) containing templated Kubernetes manifests plus a values.yaml of defaults, fetchable from a repository, installable, upgradeable, and removable as a single unit. That's the whole pitch, and it's why Helm charts are how almost every piece of third-party Kubernetes software ships today, from an ingress controller to a full observability stack.

◆ 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; you can install the same chart three times under three release names in one cluster and Helm tracks them as three unrelated things. A revision is a numbered snapshot of one release: revision 1 is the install, each successful helm upgrade produces revision 2, 3, 4, and helm rollback re-applies an old revision's manifests as a brand-new revision rather than rewinding history. Get those three words straight and the CLI stops feeling arbitrary.

The other fact worth knowing before anything else: Helm 3 has no server-side component. Helm 2 ran a cluster-wide, over-privileged daemon called Tiller that had to be trusted by every namespace it touched — deleted entirely in Helm 3. The helm binary today is a pure client: it renders templates on your machine, talks to the Kubernetes API using your kubeconfig and your RBAC, and writes down what it did. "Installing Helm" means putting a binary on a laptop or a CI runner, not standing up an in-cluster service.

Where Helm fits: next to kubectl, downstream of CI, and where it hands off to GitOps

☺ Like you're 10: Helm turns your knob settings into real YAML right before delivery — CI decides what image goes in the box, Helm builds the box, and something else carries it through the door.

Helm sits at a specific point in the delivery chain, and it's worth being precise about the boundary on either side of it. Upstream, a job in your CI/CD pipeline builds a container image, pushes it to a registry, and bumps an image tag — usually in a values file. Helm's job is the step immediately after: turn "checkout, six replicas, prod resource limits, image tag 1.4.4" into concrete Kubernetes API objects. Downstream, one of two things happens — either the pipeline itself runs helm upgrade --install directly against the cluster (simple, and fine for smaller teams), or a GitOps reconciler such as Argo CD renders and applies the chart continuously from a Git repository instead, which is the model most platforms graduate to once "who ran the last deploy, and from where" needs a definitive answer.

Compared to its neighbors in this course: infrastructure as code tools like Terraform provision the cluster itself and the cloud resources around it — the VPC, the node pool, the load balancer in front of it — while Helm only ever templates objects inside a Kubernetes API that already exists. Configuration management tools like Ansible push state onto machines over SSH; Helm never touches a machine directly, it only ever talks to the Kubernetes API server. And unlike either of those, Helm does nothing between commands — it isn't a control loop, isn't watching for drift, and doesn't correct a hand-edited Deployment on its own. If continuous reconciliation against Git is what you want, that's Argo CD's job, not Helm's.

On a real platform, Helm plays two distinct roles, and most teams do both at once. The first is consumption: an ingress controller, a metrics stack, a secrets operator — nearly every serious piece of Kubernetes infrastructure ships as an upstream chart, and installing it any other way means forking someone else's YAML forever. The second is publication: the platform team writes one internal, paved-road chart — "the standard web service" — and every application team consumes it with a short values file, picking up security and networking fixes by bumping a version number rather than copy-pasting YAML. That second role is a genuine adoption win, and it's the specific thing this page's brief is pointing at when it says teams standardize on charts instead of hand-maintained manifests: the paved road arrives as a version bump, not a pull request against every service's raw YAML.

Chart, release, revision — and the render pipeline that connects them

☺ Like you're 10: Helm reads your knob settings, fills in the blanks on the instruction sheet, posts the finished pages to the cluster, and writes down exactly what it posted.

Helm installs no controllers and defines no CRDs of its own. Its "architecture" is a pipeline that runs almost entirely on your machine, plus one storage convention inside the cluster. That pipeline is most of the debugging skill worth having.

Chart Chart.yaml values.yaml templates/ · crds/ charts/ (subcharts) Your overrides -f values-prod.yaml --set image.tag=1.4.4 merge values --set beats -f -f beats defaults render Go text/template + Sprig · _helpers.tpl text in, text out Kubernetes API 3-way merge patch your RBAC, your kubeconfig Release Secret sh.helm.release.v1. orders.v7 apply record All of this runs on your machine — Helm 3 has no in-cluster server. Only the last two arrows touch the cluster.

After a successful install or upgrade, Helm stores 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>, sitting 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 and Helm forgets the release ever existed while the workloads it created keep running — which is exactly what makes this history both genuinely useful and genuinely fragile.

The chart directory, and the two files you write most

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

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

orders/
├── Chart.yaml            # name, version, appVersion, dependencies — the only mandatory file
├── values.yaml           # the DEFAULT knob settings; also your user-facing documentation
├── values.schema.json    # optional JSON Schema — rejects bad values BEFORE rendering
├── templates/
│   ├── deployment.yaml   # Go templates → rendered into manifests
│   ├── service.yaml
│   ├── configmap.yaml    # hashed into a pod annotation by the checksum trick below
│   ├── ingress.yaml
│   ├── _helpers.tpl      # leading underscore = NOT rendered as a manifest; named templates
│   ├── NOTES.txt         # templated; printed to the user 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.
├── charts/               # vendored subcharts, populated by `helm dependency update`
├── Chart.lock            # pinned dependency versions + digest
└── .helmignore           # what to leave out when packaging

Chart.yaml and values.yaml together are the contract a chart offers its users. Chart.yaml is metadata and dependencies; values.yaml is every knob, its default, and — because most engineers read it before they read anything else — the closest thing the chart has to documentation, so comment it like documentation.

# Chart.yaml
apiVersion: v2                 # v2 = the Helm 3+ chart API
name: orders
description: The Acme orders service
type: application               # or "library" for a helpers-only chart
version: 2.4.0                  # the CHART version — bump on every chart change (SemVer)
appVersion: "1.4.3"              # 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 your user-facing documentation. Comment every knob.
replicaCount: 2
image:
  repository: ghcr.io/acme/orders
  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 }
ingress:
  enabled: false
  host: ""
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 "orders.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "orders.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
---
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "orders.fullname" . }}
  labels: {{- include "orders.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 "orders.labels" . | nindent 8 }}
    spec:
      securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: orders
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: DB_HOST
              value: {{ required "dbHost is required in prod" .Values.dbHost | quote }}
          resources: {{- toYaml .Values.resources | nindent 12 }}

What makes that render possible is a small set of built-in objects every template can see, 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 --setAlways. This is 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 must work across more than one cluster version
.FilesNon-template files inside the chart (.Get, .Glob)Dropping a real config file into a ConfigMap verbatim
.Template.Name and .BasePath of the template currently renderingThe checksum/config pod-roll trick used above

Templating with values files: precedence, environments, and the schema that catches typos

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

The whole reason charts stay reusable across dev, staging, and prod is that values layer, and the layering order is worth memorizing rather than guessing at under pressure: --set / --set-string / --set-json on the command line beats every -f file (with later -f 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 holding chart-author defaults, values-dev.yaml, values-staging.yaml, values-prod.yaml each holding only the handful of keys that differ — and pass the right one at deploy time from the CI/CD pipeline, keeping --set for the one thing that genuinely changes every run, usually the image tag.

⚠ Values are not remembered between upgrades

This is 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, and 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 whatever's new) or, better, never rely on flags for anything that matters — put the real value in a committed values-prod.yaml so "what is prod set to?" has a permanent, reviewable answer instead of living in someone's shell history.

A chart consumed by more than its own author should refuse bad input rather than silently render something wrong. Without a schema, replicasCount: 6 — one letter off from replicaCount — is simply an unused key: Helm renders the default of 2 and nobody finds out until the incident. values.schema.json is plain JSON Schema, validated by Helm during install, upgrade, lint, and template, and it turns that same typo into a hard failure before anything ever reaches the API server.

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["image", "replicaCount"],
  "properties": {
    "replicaCount": { "type": "integer", "minimum": 1, "maximum": 50 },
    "image": {
      "type": "object",
      "required": ["repository"],
      "properties": {
        "repository": { "type": "string", "pattern": "^ghcr\\.io/acme/" }
      }
    }
  }
}

Day-to-day commands

☺ Like you're 10: A handful of commands cover almost everything: add the shelf, see what's on it, render it, ship it, undo it.

# repositories, search, and inspecting a chart before you touch it
$ helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
$ helm repo update                                # refresh every repo's index — skip this and versions go stale
$ helm search repo ingress-nginx --versions       # what's available locally
$ helm show values ingress-nginx/ingress-nginx > /tmp/defaults.yaml

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

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

# the idempotent workhorse: install if absent, upgrade if present — use this, not `helm install`
$ helm upgrade --install orders ./orders \
    --namespace orders --create-namespace \
    -f values.yaml -f values-prod.yaml \
    --set image.tag=1.4.4 \
    --atomic --timeout 5m                          # --atomic auto-rolls-back on failure or timeout

$ helm diff upgrade orders ./orders -f values-prod.yaml   # PLUGIN, not built in — install on day one

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

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

On a throwaway kind or minikube cluster: helm create demo, then helm template demo ./demo and read every rendered line against the template line that produced it. Break it on purpose — change one nindent 8 to indent 8 and render again to watch a block collapse into the wrong place in the YAML. Install with helm upgrade --install, then run kubectl get secret -l owner=helm and look — your release lives there as data, not magic. Now 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.

Gotchas and failure modes

☺ Like you're 10: Helm is cutting and pasting text, so spacing matters enormously — and a few things it installs, it never cleans back up.

The render engine has no idea it is producing YAML — it concatenates text, and only the API server ever discovers the result was malformed. That's the whole reason indent N (pad every line by N spaces) and nindent N (emit a newline first, then pad — what you want right after a key:) matter so much: get them backwards and a whole block silently becomes a sibling of the key it should have nested under. Unquoted scalars are the other classic — value: {{ .Values.logLevel }} can render on or 1.10 as a YAML boolean or float instead of the string you meant, and the object that results gets rejected by the API server for reasons that look nothing like the actual bug. Pipe through | quote by reflex, and when a render looks insane, stop guessing and run helm template to see the actual text.

CRDs in crds/ are a one-way door — memorize this one. Files there install before anything in templates/, are never templated (no {{ }} at all), and are never upgraded and never deleted by Helm. A chart that adds a field to its CRD in version 3 will happily render v3 custom resources against a still-installed v2 CRD, and the API server silently strips or rejects the new field. helm uninstall leaves the CRDs in the cluster forever, too. The fix is operational: 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.

An interrupted helm upgrade — a killed CI job, a network blip — leaves the release stuck in pending-upgrade, and every later attempt fails with "another operation (install/upgrade/rollback) is in progress." There's no lock file to delete; the state lives inside the release Secret itself. helm rollback to the last good revision usually clears it; in the worst case, delete the pending sh.helm.release.v1.* Secret by hand. Two related traps: Helm keeps only the last ten revisions by default (--history-max), so a rollback target can age out of history before you need it; 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. Meanwhile every helm upgrade performs a three-way merge across the old manifest, the new manifest, and live cluster state — which is what lets an autoscaler's replica count survive an upgrade untouched, but also means a hand-edited resource produces a diff nobody expected.

Last trap, and it's the one that bites GitOps setups specifically: charts that use randAlphaNum, now, or lookup render differently on every single pass. Run under a reconciler like Argo CD, that's permanent, meaningless drift the reconciler keeps trying to "fix." Run through a plain helm template, lookup just returns empty, because nothing ever contacts the cluster during a template-only render. It's the same non-idempotency trap infrastructure as code warns about in the abstract, reachable here through three specific, easy-to-reach-for functions.

Helm vs. the alternatives

☺ Like you're 10: Other tools also turn settings into YAML — they just trade power for safety in different amounts.

The realistic question is rarely "Helm or nothing." Most platforms consume Helm charts regardless, because that's how third-party Kubernetes software ships. The real decision is what to use for configuring the workloads you own.

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 that's blind to YAML structure; deep charts sprawl values; a second state store (release Secrets) sitting beside the cluster's own desired state
Plain manifests + Kustomize-style overlaysTemplate-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; overlay chains get hard to trace once they run deep
Terraform's Kubernetes/Helm providersManage Kubernetes objects, or even trigger a Helm release, from inside a Terraform applyCluster and workload already live in the same Terraform state and change togetherTwo very different execution models glued together; a failed apply mid-way is harder to reason about than either tool alone
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
Argo CD / GitOps reconcilersContinuously reconcile a cluster to a Git repo's desired state — often rendering a Helm chart internally to get thereYou want drift caught and corrected automatically, not just applied onceA different layer, not a substitute — most GitOps setups still use Helm to produce the manifests they reconcile

The practical rule most mature platforms settle on: use Helm when an artifact has to travel — across teams, across clusters, across an organization — and when 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 doesn't appear as a named domain on the AWS DevOps Engineer – Professional (DOP-C02) exam covered on this course's certifications page, but it shows up as connective tissue wherever that exam touches container delivery — see Configuration Management & IaC. It's more directly relevant if CKAD is on your list instead: recent versions of that curriculum explicitly expect you to use Helm to install and upgrade a chart under exam conditions, so it's worth confirming the current weighting on the CNCF's own curriculum page before you plan study time around it.

🎬 At the Ship-It Guild
🐙

Olly the Octopus: Chart's rendered, upgrade's applied — eight arms, zero objects out of sync. Except this new CustomResourceDefinition field isn't showing up on the resource.

🦊

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

🐙

Olly: Because I 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 old CRD from install day.

🦫

Benny the Beaver: Same instinct as the pipeline, honestly. I don't let a build silently skip a step just because it's inconvenient — you apply the new CRD by hand first, then let the upgrade run.

👺

Gizmo: Or just helm uninstall and reinstall clean. Two seconds, brand-new CRD, done. 🤑

🐢

Timmy the Turtle: And every record in that CRD, gone with it, on a resource nobody backed up first. kubectl apply -f the new CRD, verify it, then upgrade. I'm not promoting the shortcut.

✓ 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. Put these in precedence order: chart values.yaml, --set, -f values-prod.yaml. 4. You run helm upgrade without repeating last week's --set flags. What happens, and which two flags relate to it? 5. Your chart ships a CRD in crds/ and you add a field to it in the next version. What does a plain helm upgrade do about that field? 6. Give one reason a team standardizes on an internal Helm chart instead of letting every service hand-maintain its own raw manifests.

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 — Helm 3 has no server-side component at all.
  3. Lowest to highest: chart values.yaml-f values-prod.yaml (later files beat earlier ones) → --set.
  4. Those values are forgotten — Helm re-renders from chart defaults plus only what you pass this run, so a replica count or similar override can silently revert. The two flags are --reuse-values (merge the previous release's supplied values with anything new) and --reset-values (discard them and start from chart defaults). The sturdier fix is keeping the real value in a committed values file instead of relying on either flag.
  5. Nothing. Files in crds/ install only if absent and are never upgraded or deleted by Helm, regardless of chart version. You have to apply the new CRD yourself — typically kubectl apply -f from the new chart's crds/ — before running the Helm upgrade, or the new field is silently ignored.
  6. Any of: it turns a security or reliability fix into a one-line version bump instead of a pull request against every service's raw YAML; it prevents environments from silently drifting apart the way copy-pasted manifest folders do; or it gives every release a tracked, rollback-able history that hand-maintained manifests never have.