Helm — the 80/20 course for static Kubernetes clusters¶
Scope: install and upgrade Helm charts on an already-running Kubernetes cluster (EKS, GKE, AKS, on-prem, kind), and author the kind of small app charts you'd ship internally. Cluster provisioning, custom plugin development, and library-chart engineering at scale are intentionally out of scope — pointers at the end.
Read this top-to-bottom once, then come back to specific sections as reference. This module's glossary is the dictionary for any term you don't recognise here — it also seeds the Retain flashcards.
TL;DR — in 30 seconds:
- Helm packages hand-maintained Deployment/Service/Ingress/ConfigMap/Secret YAML into one versioned chart plus one values file per install, instead of duplicating YAML per environment.
- Helm 3 is client-only — no in-cluster component — and every install/upgrade is a tracked release, recorded as a
helm.sh/release.v1Secret in the target namespace, one per revision. helm upgrade --install --atomic --waitis the CI-safe pattern: it works whether the release exists yet, rolls back automatically on a failed install, and blocks until resources report ready.
1. Why Helm — and when not to use it¶
Plain Kubernetes asks you to maintain YAML by hand: a Deployment, a Service, an Ingress, a ConfigMap, a Secret, repeated for every environment, with every value (image tag, replica count, hostname) duplicated across files. Helm replaces this with one templated package and one set of values per install.
What you get:
- Charts. A versioned, packaged template set with default values. Reusable across environments and across consumers.
- Releases. Every install is a named, tracked unit. Helm knows what it deployed, when, with which values, at which revision.
- Upgrades and rollbacks.
helm upgradere-renders with new values or a new chart version;helm rollbackreturns to a previous revision. - Discoverability. Public charts (ingress-nginx, cert-manager, postgres, redis, datadog, …) ship as installable packages. Most production stacks are 70% public charts + 30% your own.
When not to reach for Helm:
- You're shipping one tiny thing with two files of YAML, never to be reused →
kubectl apply -fis fine. - You want pure declarative reconciliation continuously → ArgoCD/Flux on top of Helm (they consume charts) or raw GitOps with Kustomize.
- You need cluster bootstrapping (CRDs that must exist before namespaces, control-plane components) → consider Operators or static manifests applied during install.
The 80/20 sweet spot Helm nails: packaged, versioned application deploys you can install, upgrade, and roll back deterministically — the layer Helmfile orchestrates above.
2. Mental model¶
+---------------------+ render +-----------------------+ apply +---------------------+
| Chart (on disk) | ----------> | Manifests (in memory) | --------> | Kubernetes cluster |
| - Chart.yaml | Helm CLI | YAML for Deployment, | helm | Workloads |
| - values.yaml | + values | Service, Ingress, etc.| install/ | Helm release state |
| - templates/*.yaml | | | upgrade | (Secret per rev.) |
+---------------------+ +-----------------------+ +----------+----------+
|
|
+-----------v----------+
| Release state |
| Secret(s) of type |
| helm.sh/release.v1 |
+----------------------+
Three things to internalise:
- Helm 3 is client-only. No Tiller, no in-cluster Helm component.
helmis a Go binary that reads charts, renders templates, and talks to the Kubernetes API the same waykubectldoes. - A release lives in a namespace as a Secret. Each install + each upgrade writes a new
helm.sh/release.v1Secret containing the rendered manifests and the values used.kubectl get secret -A -l owner=helmshows them all. Lose them and Helm forgets the release. - Templating happens before the API server sees anything. Helm renders YAML, validates the schema client-side, then sends a regular apply. Template errors are client-side errors; admission errors are cluster-side errors. Different fixes.
3. Setup on the control machine¶
# Helm 3.13+ — the current line.
brew install helm # or: https://github.com/helm/helm/releases
# Sanity
helm version
kubectl config current-context # the cluster Helm will touch
Helm reads the same kubeconfig as kubectl. Switching the kubectl context switches Helm's target. The first thing your operator brain has to internalise: helm install doesn't ask "which cluster?" — it uses the current context. Always check before you run.
A couple of plugins are common enough to install up front:
helm plugin install https://github.com/databus23/helm-diff
helm plugin install https://github.com/jkroepke/helm-secrets
helm plugin list
Add a few of the canonical public repositories:
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo add jetstack https://charts.jetstack.io
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update # refresh the local cache
helm search repo ingress-nginx # confirm
That's the working baseline.
4. Anatomy of a chart¶
A chart is a directory with a fixed layout. Scaffold one with helm create:
helm create demo-app
tree demo-app
You get:
demo-app/
├── Chart.yaml # metadata
├── values.yaml # defaults users override
├── templates/
│ ├── _helpers.tpl # named templates (private)
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── hpa.yaml
│ ├── serviceaccount.yaml
│ ├── tests/
│ │ └── test-connection.yaml
│ └── NOTES.txt # printed after install
├── charts/ # subcharts go here (often empty)
└── .helmignore # globs excluded from `helm package`
The two files you'll edit constantly:
Chart.yaml — metadata. Bump version: on every change.
apiVersion: v2 # always v2 on Helm 3
name: demo-app
description: A demo application.
type: application # or 'library' for templates-only charts
version: 0.1.0 # chart version — SemVer; bump on chart change
appVersion: "1.0.0" # the app version this chart deploys
maintainers:
- name: Example Platform Team
email: platform@example.com
dependencies: []
values.yaml — defaults. Conventionally hierarchical, mirrored in template references as .Values.foo.bar.
replicaCount: 2
image:
repository: ghcr.io/example/demo-app
tag: "" # empty -> falls back to Chart.appVersion
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: nginx
host: demo.example.com
tls:
enabled: false
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
The rest is conventions:
_helpers.tpl— anything you'd want to reuse across templates (a labels block, a name prefix). Files starting with_are not rendered as standalone manifests.NOTES.txt— rendered and printed at the end ofhelm install. The right place to put "your app is reachable at https://{{ .Values.ingress.host }}".crds/— special directory at the chart root for CustomResourceDefinitions. Helm installs CRDs before templates render so templates can reference the new kinds. Helm never upgrades CRDs (they're a one-way door); document the upgrade story manually.
5. Templates and values¶
Every file in templates/ is a Go template that renders to a Kubernetes manifest. The template context exposes:
| Variable | What it is |
|---|---|
.Values |
Merged values tree (defaults + -f + --set). |
.Release |
Release metadata: .Name, .Namespace, .Revision. |
.Chart |
Chart metadata: .Name, .Version, .AppVersion. |
.Capabilities |
Cluster capabilities: Kubernetes version, available APIs. |
.Files |
Non-template files in the chart (read with .Get). |
.Template |
The current template's name and base directory. |
A minimal templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "demo-app.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "demo-app.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
resources:
{{- toYaml .Values.resources | nindent 12 }}
The named templates referenced via include live in _helpers.tpl:
{{/*
The fully qualified name: <release>-<chart>, truncated to 63 chars.
*/}}
{{- define "demo-app.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "demo-app.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "demo-app.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
Templating rules that bite:
- YAML is indentation-sensitive.
nindent Nis "newline + N spaces";indent Nis "just N spaces". Usenindentwhen starting a block,indentwhen continuing one. Read the rendered output withhelm templatewhenever indentation looks off. {{-and-}}strip whitespace. Without them, an{{ if }}/{{ end }}block leaves a blank line in the output, which usually doesn't matter but occasionally breaks tooling.defaultis your friend.{{ .Values.image.tag | default .Chart.AppVersion }}lets users override the image tag while falling back to the chart'sappVersion.toYamlre-serialises a value. Use it to pass a whole sub-tree to the manifest (resources,nodeSelector,tolerations).tplre-renders a string.{{ tpl .Values.someTemplate . }}lets a user supply a Go-template snippet invalues.yamlthat's rendered against the current context.
6. Your first install¶
helm install demo-app ./demo-app # release name 'demo-app', current namespace
helm install demo-app ./demo-app -n demo --create-namespace
helm install demo-app ./demo-app -f overrides.yaml --set replicaCount=4
helm install demo-app ./demo-app --dry-run --debug # render + validate, don't apply
helm install demo-app ./demo-app --atomic --wait --timeout 5m
Three flags worth memorising up front:
--atomic— if the install fails partway, uninstall everything it created. Safer default in CI.--wait— block until every resource reports ready. Critical for chained installs.--timeout 5m— how long to wait. The default 5 minutes is fine for most apps; bump for slow Helm hooks.
Subsequent updates use upgrade:
helm upgrade demo-app ./demo-app # values from defaults
helm upgrade demo-app ./demo-app -f overrides.yaml --atomic --wait
helm upgrade --install demo-app ./demo-app -f overrides.yaml --atomic --wait # install OR upgrade
The helm upgrade --install form is what every CI pipeline should use: it works whether or not the release exists.
Read the install output:
NAME: demo-app
LAST DEPLOYED: Wed May 20 12:34:56 2026
NAMESPACE: demo
STATUS: deployed
REVISION: 1
NOTES:
The application has been installed. To access it, run:
kubectl port-forward svc/demo-app 8080:80
STATUS: deployed and REVISION: 1 are what you want. Anything else (failed, pending-install, pending-upgrade) means something went wrong — see helm status demo-app for details.
7. Releases and revisions¶
A release is one named installation of a chart in one namespace. A revision is one snapshot of that release's manifests + values + chart version. Every install/upgrade/rollback bumps the revision.
helm list # current namespace
helm list -A # all namespaces
helm list -n demo --filter '^demo-' # regex filter
Inspect a release:
helm status demo-app -n demo
helm get values demo-app -n demo # the merged values used
helm get manifest demo-app -n demo # the rendered manifests
helm get notes demo-app -n demo # the NOTES.txt
helm get all demo-app -n demo # everything
Release state itself is in the namespace as Secrets:
kubectl -n demo get secret -l owner=helm,name=demo-app
# sh.helm.release.v1.demo-app.v1
# sh.helm.release.v1.demo-app.v2
# sh.helm.release.v1.demo-app.v3
One Secret per revision. Helm 3 garbage-collects very old revisions by default (10 most recent kept); tune with --history-max on upgrade.
8. History and rollback¶
helm history demo-app -n demo
# REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
# 1 Wed May 20 12:34:56 2026 superseded demo-app-0.1.0 1.0.0 Install complete
# 2 Wed May 20 13:01:12 2026 superseded demo-app-0.1.1 1.1.0 Upgrade complete
# 3 Wed May 20 13:45:00 2026 deployed demo-app-0.1.1 1.1.0 Upgrade complete
helm rollback demo-app 1 -n demo --wait --atomic
helm history demo-app -n demo
# REVISION ... STATUS ...
# 1 ... superseded ...
# 2 ... superseded ...
# 3 ... superseded ...
# 4 ... deployed ... Rollback to 1
Two things to know:
- Rollback creates a new revision. Revision 4 above is "the state of revision 1, re-applied". This is intentional — every change is forward-only in history.
- Rollback uses the stored manifests, not the chart on disk. Even if the chart in your repo no longer exists at the rolled-back version, the rollback still works because Helm replays the manifests it persisted.
As a timeline, the four revisions above look like this — history only ever moves forward, and a rollback is a new revision that replays an old one, not a deletion of what came after it:
flowchart LR
R1["Revision 1<br>helm install<br>superseded"]
R2["Revision 2<br>helm upgrade<br>superseded"]
R3["Revision 3<br>helm upgrade<br>superseded"]
R4["Revision 4<br>helm rollback 1<br>deployed"]
R1 --> R2 --> R3 --> R4
R1 -.->|"replays R1's stored manifests"| R4
If rollback fails (e.g. CRDs changed in incompatible ways), you're in for manual cleanup: helm uninstall --keep-history + helm install from a known-good chart, or kubectl surgery.
9. Repositories — classic and OCI¶
Classic Helm repository. An HTTPS endpoint serving an index.yaml plus chart .tgz files.
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm search repo ingress-nginx # list charts in this repo
helm search repo ingress-nginx --versions # list every version
helm pull ingress-nginx/ingress-nginx --version 4.10.0 # download .tgz to cwd
helm pull ingress-nginx/ingress-nginx --untar # download + extract
The list of known repos lives at ~/.config/helm/repositories.yaml. CI runners forget; always helm repo add && helm repo update early in the pipeline.
OCI registry. Since Helm 3.8, OCI-compatible registries (Docker Hub, GHCR, ECR, Harbor, Artifactory) can serve charts as OCI artifacts. No repositories.yaml entry needed.
# Push
helm registry login ghcr.io --username USER --password TOKEN
helm package ./demo-app # writes demo-app-0.1.0.tgz
helm push demo-app-0.1.0.tgz oci://ghcr.io/example/charts
# Pull / install
helm pull oci://ghcr.io/example/charts/demo-app --version 0.1.0
helm install demo-app oci://ghcr.io/example/charts/demo-app --version 0.1.0
OCI is the modern default. Two reasons to use it: you already run a container registry (so no separate Helm hosting), and pulling chart + image from the same registry under the same IAM is simpler.
Inspect without installing.
helm show chart ingress-nginx/ingress-nginx # Chart.yaml
helm show values ingress-nginx/ingress-nginx # default values
helm show readme ingress-nginx/ingress-nginx # readme
helm show all ingress-nginx/ingress-nginx # everything
The first thing you do with a new chart, always.
10. Dependencies and subcharts¶
A chart can declare other charts as dependencies. They render together as one release.
Chart.yaml:
apiVersion: v2
name: demo-stack
version: 0.1.0
dependencies:
- name: redis
version: 18.x.x
repository: https://charts.bitnami.com/bitnami
condition: redis.enabled # toggle by value
- name: postgresql
version: 15.x.x
repository: https://charts.bitnami.com/bitnami
alias: db # rename to avoid clashes
condition: db.enabled
- name: oci-thing
version: 2.0.0
repository: oci://ghcr.io/example/charts # OCI dep
Then:
helm dependency update ./demo-stack # fetches deps into charts/, writes Chart.lock
helm dependency build ./demo-stack # reads Chart.lock, rebuilds charts/ deterministically
helm dependency list ./demo-stack
Two things worth knowing:
- Commit
Chart.lock. It pins the exact subchart versions resolved.helm dependency buildreads it;helm installdoesn't reach the network ifcharts/is already populated. - Conditions and tags are values-driven on/off switches.
condition: redis.enabledreads.Values.redis.enabled; tags work the same way grouped under.Values.tags.<name>. Use them so the same chart can be installed with different subchart subsets per env.
Subchart values live under the subchart's key in the parent's values:
# values.yaml in demo-stack
redis:
enabled: true
auth:
enabled: false # passed straight to bitnami/redis
db:
enabled: true
global:
postgresql:
auth:
database: demo
The parent's --set redis.replicaCount=2 reaches redis.replicaCount inside the redis subchart's values.
Library charts. A chart with type: library in Chart.yaml ships only named templates, never installable resources. Application charts depend on it; the templates are available via include after helm dependency build. Useful when you have a fleet of charts that need the same labels, the same affinity rules, the same probe definitions.
11. Hooks¶
A chart hook is a manifest in templates/ annotated with helm.sh/hook: <event>. Helm renders it like everything else but applies it around the release lifecycle instead of as part of the regular set.
Events you'll actually use:
| Event | Fires when |
|---|---|
pre-install |
Before any chart template is applied on first install. |
post-install |
After the chart's resources are applied and ready. |
pre-upgrade |
Before applying the new revision's templates. |
post-upgrade |
After the upgrade's resources are applied. |
pre-rollback |
Before applying a rollback's templates. |
post-rollback |
After rollback resources are applied. |
pre-delete |
Before helm uninstall removes resources. |
post-delete |
After helm uninstall removes resources. |
test |
Only on helm test; never on install/upgrade. |
A typical pre-upgrade Job that takes a backup:
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "demo-app.fullname" . }}-pre-upgrade-backup
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: backup
image: ghcr.io/example/backup:1.0.0
args: ["./backup.sh", "--release={{ .Release.Name }}"]
Three annotations matter:
helm.sh/hook— one or more events, comma-separated.helm.sh/hook-weight— integer; within an event, hooks run in ascending weight. Negative weights run first.helm.sh/hook-delete-policy— when to delete the hook's resource.before-hook-creationis the default;hook-succeededcleans up after success;hook-failedcleans up after failure;before-hook-creation,hook-succeededis the safest combo for one-shot Jobs.
Rule of thumb: if the action belongs in the cluster (run a Job to migrate the DB), use a chart hook. If it belongs on the operator's machine or CI (fetch a chart, post to Slack), use a Helmfile hook.
12. Tests¶
A test is a hook with helm.sh/hook: test, typically a Pod that runs a quick verification (a curl to a health endpoint, a psql -c 'SELECT 1', …).
templates/tests/test-connection.yaml:
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "demo-app.fullname" . }}-test-connection"
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
restartPolicy: Never
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "demo-app.fullname" . }}:{{ .Values.service.port }}']
Run it after install/upgrade:
helm test demo-app -n demo
The test runs as part of the release; it does not run automatically on helm install or helm upgrade. Treat it as your post-deploy smoke test.
13. Authoring your own chart — the things you'll get wrong¶
helm create gives you a working skeleton. From there:
- Replace the boilerplate
_helpers.tplreferences —helm createnames them after the chart but with bugs around hyphenation. Read the generated file and adapt; don't trust the labels block blindly. - Pin every image tag. Default
image.tag: ""falls back toChart.appVersion. BumpappVersionon every container release; commit it. - Provide
values.schema.jsonfor charts other people consume. JSON Schema gives users typed errors instead of "your YAML rendered to nonsense at line 42".helm installvalidates against it automatically. - Don't generate secrets inside the chart.
randAlphaNum 16is tempting; it makes upgrades non-deterministic because every render rolls a new value. Generate secrets out-of-band, supply them as values. - Mark sensitive outputs. Anything in
NOTES.txtends up inhelm get notesand probably in logs. Don't print passwords. - Treat CRDs carefully. Charts that ship CRDs in
crds/install them once and never upgrade them. For upgrades, the operator runskubectl apply -f crds/manually. Document this in the README. - Lint and template before commit.
helm lint ./demo-app
helm template demo-app ./demo-app -f overrides.yaml | less
helm template demo-app ./demo-app --debug 2>&1 | head -50 # surfaces template errors with file:line
helm template with --debug is the single most useful command when something doesn't render right.
14. Linting, packaging, publishing¶
helm lint catches structural and template errors before they reach the cluster. Run it in CI on every PR.
helm lint ./demo-app
helm lint ./demo-app --strict # fail on warnings
helm lint ./demo-app --values overrides.yaml # lint with specific values
Package and publish (OCI flow):
helm package ./demo-app --version 0.1.0 --app-version 1.0.0
# Writes demo-app-0.1.0.tgz
helm registry login ghcr.io --username USER --password "$GH_TOKEN"
helm push demo-app-0.1.0.tgz oci://ghcr.io/example/charts
Classic-repo flow (less common today but still in use):
helm package ./demo-app -d ./_dist
helm repo index ./_dist --url https://charts.example.com
# Upload ./_dist/* to the HTTP endpoint
A practical CI pipeline:
lint → template (validate against policies) → package → push (on tag)
Don't push unversioned charts. Don't reuse a version. Every push is immutable.
15. Worked example — a small app chart¶
End-to-end. A chart for a stateless HTTP service with a Deployment, Service, Ingress, ConfigMap, and Secret reference. Production-quality minimum.
demo-app/
├── Chart.yaml
├── values.yaml
├── values.schema.json
├── README.md
├── templates/
│ ├── _helpers.tpl
│ ├── NOTES.txt
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ ├── serviceaccount.yaml
│ └── tests/
│ └── test-connection.yaml
└── .helmignore
Chart.yaml:
apiVersion: v2
name: demo-app
description: A demo application.
type: application
version: 0.1.0
appVersion: "1.0.0"
home: https://github.com/example/demo-app
maintainers:
- name: Example Platform Team
email: platform@example.com
values.yaml:
replicaCount: 2
image:
repository: ghcr.io/example/demo-app
tag: "" # fallback to .Chart.AppVersion
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: nginx
host: demo.example.com
tls:
enabled: false
secretName: ""
config:
logLevel: info
featureFlags: []
secretRef: # external Secret created out-of-band
name: ""
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
probes:
liveness:
path: /healthz
port: http
readiness:
path: /ready
port: http
templates/_helpers.tpl:
{{- define "demo-app.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "demo-app.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
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 -}}
{{- define "demo-app.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "demo-app.fullname" . }}
labels: {{- include "demo-app.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels: {{- include "demo-app.selectorLabels" . | nindent 6 }}
template:
metadata:
labels: {{- include "demo-app.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
envFrom:
- configMapRef:
name: {{ include "demo-app.fullname" . }}
{{- if .Values.secretRef.name }}
- secretRef:
name: {{ .Values.secretRef.name }}
{{- end }}
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: {{ .Values.probes.liveness.port }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path }}
port: {{ .Values.probes.readiness.port }}
resources: {{- toYaml .Values.resources | nindent 12 }}
templates/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: {{ include "demo-app.fullname" . }}
labels: {{- include "demo-app.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector: {{- include "demo-app.selectorLabels" . | nindent 4 }}
templates/ingress.yaml:
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "demo-app.fullname" . }}
labels: {{- include "demo-app.labels" . | nindent 4 }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "demo-app.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts: [{{ .Values.ingress.host | quote }}]
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
{{- end }}
templates/configmap.yaml:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "demo-app.fullname" . }}
labels: {{- include "demo-app.labels" . | nindent 4 }}
data:
LOG_LEVEL: {{ .Values.config.logLevel | quote }}
FEATURE_FLAGS: {{ .Values.config.featureFlags | toJson | quote }}
templates/serviceaccount.yaml:
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "demo-app.fullname" . }}
labels: {{- include "demo-app.labels" . | nindent 4 }}
templates/tests/test-connection.yaml:
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "demo-app.fullname" . }}-test"
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
restartPolicy: Never
containers:
- name: wget
image: busybox
command: ['wget']
args: ['-q', '-O-', 'http://{{ include "demo-app.fullname" . }}:{{ .Values.service.port }}/']
templates/NOTES.txt:
{{ include "demo-app.fullname" . }} installed in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
Reach the app at: http{{ if .Values.ingress.tls.enabled }}s{{ end }}://{{ .Values.ingress.host }}
{{- else }}
Port-forward to test:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "demo-app.fullname" . }} 8080:{{ .Values.service.port }}
{{- end }}
Run the whole pipeline against a kind / minikube / staging cluster:
# 1. Lint
helm lint ./demo-app --strict
# 2. Render and inspect
helm template demo-app ./demo-app -n demo \
--set ingress.enabled=true \
--set ingress.host=demo.staging.example.com | less
# 3. Install
helm upgrade --install demo-app ./demo-app -n demo --create-namespace \
--set ingress.enabled=true \
--set ingress.host=demo.staging.example.com \
--atomic --wait --timeout 5m
# 4. Verify
helm status demo-app -n demo
helm test demo-app -n demo
kubectl -n demo get deploy,svc,ingress
# 5. Upgrade
helm upgrade --install demo-app ./demo-app -n demo \
--set replicaCount=3 --atomic --wait
# 6. Rollback
helm history demo-app -n demo
helm rollback demo-app 1 -n demo --wait --atomic
# 7. Decommission
helm uninstall demo-app -n demo
When the whole pipeline behaves, you have the 80%. Everything else (subcharts at scale, library charts, post-renderers, server-side apply) is layered on top.
16. What's intentionally out of scope here¶
| Topic | Pointer when you need it |
|---|---|
| Helm 2 / Tiller | Defunct. If you still see Tiller anywhere, migrate to Helm 3 first. |
| Custom Helm plugins (Go) | Helm plugin SDK in the docs. Write a plugin only when no combination of helm get + scripts works. |
post-renderer |
Pass --post-renderer ./script to filter the rendered manifests through an external tool (Kustomize, kbld, sed). Use when you need to patch a chart you don't own. |
| Library charts at scale | type: library charts shared across an org. Worth a separate study when you have ≥5 charts using common patterns. |
values.schema.json deep design |
JSON Schema for chart values. Adds typed validation; worth doing for shared charts. |
| Server-side apply / SSA conflicts | Helm 3 uses three-way merge by default; SSA support is evolving. Read the Helm SSA docs only if you hit field-manager conflicts. |
| Chart provenance / signing | helm package --sign, helm verify, cosign. Relevant when supply chain is regulated. |
| Helm Operator / Flux Helm Controller | Run Helm in-cluster as a controller. Different mental model from CLI; choose at the team level. |
| Helmfile | The orchestrator layer on top of Helm. Covered separately in module 06, Helmfile. |
Migrating from helm install to helm template + kubectl apply |
Niche operator preference; loses revisions and rollback. Not recommended. |
17. Reference card — daily commands¶
# Setup
helm repo add NAME URL
helm repo update
helm registry login REGISTRY # OCI
# Inspect (read-only)
helm search repo TERM
helm show chart REPO/CHART
helm show values REPO/CHART
helm show all REPO/CHART
helm template RELEASE CHART -f values.yaml # render without applying
# Install / upgrade
helm install RELEASE CHART -n NS --create-namespace -f overrides.yaml
helm upgrade --install RELEASE CHART -n NS -f overrides.yaml --atomic --wait
helm install RELEASE CHART --dry-run --debug # validate
# Releases
helm list -A
helm status RELEASE -n NS
helm get values RELEASE -n NS # what values were used
helm get manifest RELEASE -n NS # rendered manifests
helm history RELEASE -n NS
helm rollback RELEASE REVISION -n NS --wait --atomic
helm uninstall RELEASE -n NS # remove
helm uninstall RELEASE -n NS --keep-history # remove resources, keep name reserved
# Tests
helm test RELEASE -n NS
# Authoring
helm create NAME
helm lint ./NAME --strict
helm package ./NAME --version X.Y.Z
helm push NAME-X.Y.Z.tgz oci://REGISTRY/PATH
helm dependency update ./NAME
helm dependency build ./NAME
# Debug
helm get all RELEASE -n NS
helm template RELEASE CHART --debug 2>&1 | less
helm install --dry-run --debug RELEASE CHART
That's the 80%. Go run helm create my-app && helm template my-app ./my-app and read what falls out.
18. How this connects¶
- Helmfile (module 06, Helmfile) is the orchestrator one level up: its own course page already says "Helm still
owns the cluster-side state — each release is tracked by Helm in a Secret in its namespace," the exact
mechanism §7 describes here. Helmfile just drives
helm upgrade --installper release from one declarative file instead of you running the commands in this reference card by hand. - The
templates/*.yamla chart renders in §4 aren't a Helm-specific format — they're the same Deployment/Service/Ingress objects Kubernetes Workloads and Networking (module 04, Kubernetes & Helm,01-workloads.mdand02-networking.md) teach the shape and behavior of. Helm's job stops at rendering + applying them; what happens afterkubectl apply— rollout, Service routing — is that course's territory. - §9's OCI registry push/pull (
helm push/helm pull oci://…) is the same OCI artifact model Podman (module 03, Containers,01-images-and-builds.md§5) covers for container images — a chart.tgzand an image are both content-addressed blobs in the same kind of registry, just different artifact types under one spec.