Argo CD and Helm · Lesson 5 of 11 · about 25 minutes

Helm: charts, templates and values

The bookshop's plain YAML becomes a Helm chart: templates with gaps, and a values file that fills them. One chart renders production, or staging with a different name and one copy, without editing a line of it. Then Helm on its own: install, upgrade, history and rollback, and the two mistakes Helm happily accepted.

By the end you'll know

  • What a Helm chart is, and what's in each of its files
  • How templates and values fit together, and how to see the YAML they make
  • How to change settings for one environment without touching the chart
  • What Helm checks, and the mistakes it doesn't catch
  • How Helm installs, upgrades and rolls back on its own, and where it keeps its records

Before you start

  • You need: Lessons 1 to 4 done, with the gitops cluster running.
  • Install Helm: brew install helm.
  • Get the new files: git pull. You'll have charts/bookshop/.
  • Where commands run: your own computer's terminal, in gitops-practice. Argo CD isn't involved this lesson; Lesson 6 hands it the chart.

The short version

  • The problem. The bookshop's YAML has its settings baked in: the number of copies, the image, the shop's name. A staging copy would mean a second set of files, kept in step by hand.
  • A chart. Helm turns the YAML into templates with gaps, and a values.yaml that fills them. helm template shows the YAML that comes out.
  • One chart, two shops. A small staging file changed the name, the version and the copies. The chart itself didn't change.
  • Helm on its own installed a release, upgraded it, and rolled it back, keeping each version as a Secret. It also accepted replicas: two and a misspelled setting without a word.

Settings baked into files

The bookshop's YAML from Lesson 3 works, but every setting is written into it: replicas: 2 in the Deployment, nginx:1.28 in the container, "The Bookshop" in the page. Say the team wants a staging copy, to try changes before the real shop: one copy, a name that makes it obvious it isn't the real one, and a different version in the footer. With plain YAML, that's a second folder of nearly identical files, and every change made twice, until one day someone forgets.

Helm is the Kubernetes package manager, and it solves exactly this. Think of a form letter, like the Ansible templates: one letter, with gaps for the name and address, printed once per person. A Helm chart is the letter, and a values file is the list of what goes in the gaps.

The chart

BASH
brew install helm
helm version --short
find charts -type f | sort
OUTPUT
v4.2.0+g0646808

charts/bookshop/Chart.yaml
charts/bookshop/files/index.html
charts/bookshop/templates/_helpers.tpl
charts/bookshop/templates/configmap.yaml
charts/bookshop/templates/deployment.yaml
charts/bookshop/templates/service.yaml
charts/bookshop/values-staging.yaml
charts/bookshop/values.yaml

Helm 4. Each file has a job:

  • Chart.yaml: what the chart is.
  • values.yaml: the settings, with their defaults.
  • templates/: the Kubernetes YAML, with gaps. _helpers.tpl holds small reusable pieces.
  • files/index.html: the storefront, with gaps of its own.
  • values-staging.yaml: settings for staging, which override the defaults.
YAML
# Chart.yaml
apiVersion: v2
name: bookshop
description: The bookshop website, for the Argo CD and Monitoring tracks on kwamib.dev
type: application
version: 0.1.0      # the chart's own version
appVersion: "1"     # the version of the shop it deploys

Two versions, and they mean different things. version is the chart's: it goes up when the templates change. appVersion is the app's: what the chart deploys. A chart at 0.1.0 can deploy shop version 1 today and version 2 tomorrow.

YAML
# values.yaml
replicas: 2

image:
  repository: nginx
  tag: "1.28"

shop:
  name: The Bookshop
  headline: Books for people who run things.
  version: "1"

resources:
  requests:
    cpu: 10m
    memory: 16Mi
  limits:
    memory: 64Mi

Everything someone might want to change, in one place, with a sensible default. Why "1.28" and "1" in quotes? Without them, YAML reads them as numbers, and 1.28 and 1.280 would be the same number. A version is text.

Templates

templates/deployment.yaml is Lesson 3's Deployment with the settings taken out:

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
  labels:
    {{- include "bookshop.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicas }}
  selector:
    matchLabels:
      app: {{ .Chart.Name }}
  template:
    metadata:
      labels:
        {{- include "bookshop.labels" . | nindent 8 }}
    spec:
      containers:
        - name: nginx
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ...
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

It's the same {{ }} as Ansible's Jinja2, but Helm uses Go templates, so the details differ:

  • .Values.replicas reads from the values. .Release.Name is the name you give this installation, so two installations get different names. .Chart.Name is bookshop.
  • include "bookshop.labels" . | nindent 4 pastes in the labels defined in _helpers.tpl, indented 4 spaces. Indentation is the fiddly part of Helm: YAML depends on it, and templates can't see it, so you tell them.
  • toYaml .Values.resources copies a whole block of values across as YAML.

One thing deliberately not templated: the selector, app: {{ .Chart.Name }}, is exactly the app: bookshop Lesson 3 used. A Deployment's selector can't be changed once it's made, and Lesson 6 switches the running bookshop over to this chart. Different labels there, and the switch would fail.

The page works the same way. files/index.html has five gaps:

HTML
<title>{{ .Values.shop.name }}</title>
...
<h1>{{ .Values.shop.headline }}</h1>
...
Deployed by Argo CD from <code>gitops-practice</code>. Version {{ .Values.shop.version }}.

And the ConfigMap template reads it in, filling the gaps on the way:

YAML
data:
  index.html: |
    {{- tpl (.Files.Get "files/index.html") . | nindent 4 }}

.Files.Get reads a file from the chart, and tpl treats it as a template too. So the page's text lives in an HTML file you can edit, and the shop's name in one setting.

Checking, and seeing what comes out

BASH
helm lint charts/bookshop
OUTPUT
[INFO] Chart.yaml: icon is recommended

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

helm lint checks the chart is well formed. The only note is that it has no icon, which only matters for charts published for others. Then the most useful Helm command there is:

BASH
helm template bookshop charts/bookshop

helm template renders the chart into plain YAML, and prints it, without touching the cluster. The highlights:

OUTPUT
kind: ConfigMap
  name: bookshop-page
    <title>The Bookshop</title>
        <span>Deployed by Argo CD from <code>gitops-practice</code>. Version 1.</span>
kind: Service
  name: bookshop
kind: Deployment
  name: bookshop
  replicas: 2
          image: "nginx:1.28"

Every gap filled: the release name bookshop, two copies, nginx:1.28, and the shop's name and version in the page. In full, it's 233 lines, against 206 for the plain YAML: the difference is the standard labels the chart adds to everything, like app.kubernetes.io/version and helm.sh/chart. Whenever a chart does something unexpected, helm template is where to look: it shows exactly what Kubernetes would be given.

One chart, two shops

Change a value for one run:

BASH
helm template bookshop charts/bookshop --set replicas=3 | grep "  replicas"
OUTPUT
  replicas: 3

Or a whole file of them. values-staging.yaml only lists what's different:

YAML
# Staging: one copy, and a name that says it isn't the real shop.
replicas: 1
shop:
  name: The Bookshop (staging)
  version: "2-rc"

Everything it doesn't mention keeps its default. The difference it makes, as a diff between the two renders:

BASH
diff <(helm template bookshop charts/bookshop) \
     <(helm template bookshop charts/bookshop -f charts/bookshop/values-staging.yaml)
OUTPUT
12c12
<     app.kubernetes.io/version: "1"
---
>     app.kubernetes.io/version: "2-rc"
21c21
<     <title>The Bookshop</title>
---
>     <title>The Bookshop (staging)</title>
93c93
<         <div class="brand"><span class="mark">B</span> The Bookshop</div>
---
>         <div class="brand"><span class="mark">B</span> The Bookshop (staging)</div>
157,158c157,158
<         <span>The Bookshop, a practice app for the kwamib.dev tracks.</span>
<         <span>Deployed by Argo CD from <code>gitops-practice</code>. Version 1.</span>
---
>         <span>The Bookshop (staging), a practice app for the kwamib.dev tracks.</span>
>         <span>Deployed by Argo CD from <code>gitops-practice</code>. Version 2-rc.</span>
...
199c199
<   replicas: 2
---
>   replicas: 1

Five lines of settings changed the name in three places, the version in the page and every label, and the copies. Same chart, no edits. That's the whole point: one set of templates, and a small values file for each place it runs. Lesson 7 uses exactly this for staging and production.

What Helm doesn't check

Two mistakes, on purpose. A word where a number should be:

BASH
helm template bookshop charts/bookshop --set replicas=two | grep "  replicas"
OUTPUT
  replicas: two
helm exit code: 0

Accepted, exit code 0. Helm filled the gap with what it was given. Kubernetes would refuse it, but only at deploy time. And a misspelled setting:

BASH
helm template bookshop charts/bookshop --set shop.nmae=Oops | grep -c Oops
OUTPUT
0

nmae instead of name, and Helm said nothing: it set a value no template reads, and used the default name. Both mistakes pass helm template, and the second would deploy perfectly, with the wrong result. The fix is a values.schema.json in the chart: a description of every setting, its type and its allowed values, which Helm checks before rendering. It's in Go deeper.

Helm on its own

Lesson 6 has Argo CD use the chart. But Helm can install charts by itself, and it's worth seeing how, because Argo CD does it differently. A throwaway installation, called a release, named trial, with the staging values:

BASH
helm install trial charts/bookshop -n helm-trial --create-namespace -f charts/bookshop/values-staging.yaml
helm list -n helm-trial
OUTPUT
NAME: trial
LAST DEPLOYED: Wed Nov  4 00:25:25 2026
NAMESPACE: helm-trial
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete

NAME 	NAMESPACE 	REVISION	UPDATED                             	STATUS  	CHART         	APP VERSION
trial	helm-trial	1       	2026-11-04 00:25:25.889804 -0500 EST	deployed	bookshop-0.1.0	1          

.Release.Name is trial, so the Deployment and Service are called trial, beside the real bookshop, without clashing. Its page:

OUTPUT
<title>The Bookshop (staging)</title>
    <span>Deployed by Argo CD from <code>gitops-practice</code>. Version 2-rc.</span>

(The footer says Argo CD, because the page is written for the real shop. Here, Helm deployed it.) An upgrade, changing one value, then the history:

BASH
helm upgrade trial charts/bookshop -n helm-trial -f charts/bookshop/values-staging.yaml --set shop.version=3
helm history trial -n helm-trial
helm get values trial -n helm-trial
OUTPUT
Release "trial" has been upgraded. Happy Helming!
REVISION: 2

REVISION	UPDATED                 	STATUS    	CHART         	APP VERSION	DESCRIPTION     
1       	Wed Nov  4 00:25:25 2026	superseded	bookshop-0.1.0	1          	Install complete
2       	Wed Nov  4 00:25:30 2026	deployed  	bookshop-0.1.0	1          	Upgrade complete

USER-SUPPLIED VALUES:
replicas: 1
shop:
  name: The Bookshop (staging)
  version: 3

Look at that last line: version: 3, not "3". --set guesses types, and decided 3 was a number. Here it didn't matter, because the label template quotes it, but for a value that must be text, use --set-string. Then a rollback, to revision 1:

BASH
helm rollback trial 1 -n helm-trial
helm history trial -n helm-trial
kubectl get secrets -n helm-trial
OUTPUT
Rollback was a success! Happy Helming!

REVISION	UPDATED                 	STATUS    	CHART         	APP VERSION	DESCRIPTION     
1       	Wed Nov  4 00:25:25 2026	superseded	bookshop-0.1.0	1          	Install complete
2       	Wed Nov  4 00:25:30 2026	superseded	bookshop-0.1.0	1          	Upgrade complete
3       	Wed Nov  4 00:25:30 2026	deployed  	bookshop-0.1.0	1          	Rollback to 1   

NAME                          TYPE                 DATA   AGE
sh.helm.release.v1.trial.v1   helm.sh/release.v1   1      6s
sh.helm.release.v1.trial.v2   helm.sh/release.v1   1      1s
sh.helm.release.v1.trial.v3   helm.sh/release.v1   1      1s

A rollback is a new revision, 3, made from 1's settings. And there's where Helm keeps its records: one Secret per revision, in the cluster, holding the chart and values used. That's Helm's history, and it's how helm rollback knows what to go back to.

BASH
helm uninstall trial -n helm-trial && kubectl delete namespace helm-trial
kubectl get application bookshop -n argocd
OUTPUT
release "trial" uninstalled
namespace "helm-trial" deleted

NAME       SYNC STATUS   HEALTH STATUS
bookshop   Synced        Healthy

Gone, and Argo CD's bookshop never noticed.

Helm's history, or Git's?

Notice what just happened: the trial release's history lived in the cluster, in Secrets, and came from commands someone ran. That's the push model from Lesson 1 again. Delete the namespace, and the history goes with it. Nobody reviewed --set shop.version=3.

Argo CD uses Helm differently. It runs the equivalent of helm template, takes the YAML that comes out, and applies it like any other. No helm install, no release Secrets, and helm list shows nothing. The history is Git's: which values file, which chart version, at which commit. Lesson 6 shows that in action.

In real systems

  • Most software arrives as a chart. Prometheus, Grafana, databases, Argo CD itself: nearly everything has an official Helm chart. The Monitoring track installs one. You'll write fewer charts than you install.
  • Values files per environment. The usual layout is one chart, and a values file for each place it runs: values-staging.yaml, values-production.yaml. Reviewing a change to production means reading a small values diff, not a pile of YAML.
  • Charts get schemas. Shared charts ship a values.schema.json, so replicas: two or a misspelled setting fails straight away, with a message, instead of reaching the cluster.
  • Render in the pull request. Teams run helm template in CI and post the rendered diff on the pull request, so reviewers see what will actually change in the cluster, not just which values moved.

Spot the problem

Work out what's wrong before you read the explanation.

The setting that wouldn't take. Someone wants production to run four copies of the bookshop. They add replica: 4 to values-production.yaml, render it, and deploy. The bookshop keeps running two copies. They check the file: four is right there. They redeploy; still two. What's wrong? The setting is called replicas, with an s. replica is a value nothing reads, so the template fell back to the default in values.yaml, 2, exactly like shop.nmae above. Helm didn't complain because it never does: any key is allowed in values. The quick check is helm template ... | grep replicas, which shows 2, not 4. The lasting fix is a values.schema.json with "additionalProperties": false, so a key the chart doesn't know is an error.

Keep this

ToRun or write
Check a chart is well formedhelm lint charts/bookshop
See the YAML it makeshelm template NAME charts/bookshop
Change one value--set replicas=3, or --set-string for text
Use a values file-f charts/bookshop/values-staging.yaml
Compare two rendersdiff <(helm template ...) <(helm template ... -f FILE)
Read a value in a template{{ .Values.replicas }}
Paste a block, indented{{- toYaml .Values.resources | nindent 12 }}
Reuse a piecedefine in _helpers.tpl, then include "NAME" . | nindent 4
Read a file, as a template{{ tpl (.Files.Get "files/index.html") . }}
Install, upgrade, listhelm install, helm upgrade, helm list -n NAMESPACE
History, and roll backhelm history NAME, helm rollback NAME REVISION
The values a release usedhelm get values NAME
Remove a releasehelm uninstall NAME -n NAMESPACE

Where you are now

The bookshop is a Helm chart: templates with gaps, a values file of defaults, and a staging file that makes a different shop from the same chart. You've seen Helm install, upgrade and roll back on its own, and where it keeps that history. Lesson 6 gives the chart to Argo CD: the running bookshop switches from plain YAML to the chart, with no downtime, and the ConfigMap delay from Lessons 3 and 4 gets its fix.

Check yourself

  1. What's the difference between version and appVersion in Chart.yaml?

  2. What does helm template do?

  3. values-staging.yaml only lists three settings. What happens to the rest?

  4. --set shop.nmae=Oops was accepted. What did the page show?

  5. Where does Helm keep a release's history?

  6. How does Argo CD use a Helm chart?

Key terms

  • Helm: the package manager for Kubernetes.
  • Chart: a folder of templates, default values and information about them, which Helm renders into Kubernetes YAML.
  • Values: the settings that fill a chart's templates; values.yaml holds the defaults.
  • Template: a file with gaps, written in Go's template language.
  • Render: turning templates and values into plain YAML, as helm template does.
  • Release: one installation of a chart, by Helm itself, with its own name and history.
  • Revision: one version of a release; a rollback makes a new one.
  • values.schema.json: a description of a chart's settings, which Helm checks before rendering.

Go deeper

  • The Helm documentation: Charts (opens in a new tab), and the chart template guide (opens in a new tab).
  • Try: add charts/bookshop/values.schema.json with replicas as an integer from 1 to 10, and "additionalProperties": false at the top level. Run helm template with --set replicas=two, then with --set replica=4. What happens now?
  • Try: add a featured value, a book's title, and put it in the page's hero section with {{ .Values.shop.featured }}. Render it with and without the value. What does the page say when it isn't set, and how would default fix that?