Skip to content

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.v1 Secret in the target namespace, one per revision.
  • helm upgrade --install --atomic --wait is 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 upgrade re-renders with new values or a new chart version; helm rollback returns 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 -f is 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:

  1. Helm 3 is client-only. No Tiller, no in-cluster Helm component. helm is a Go binary that reads charts, renders templates, and talks to the Kubernetes API the same way kubectl does.
  2. A release lives in a namespace as a Secret. Each install + each upgrade writes a new helm.sh/release.v1 Secret containing the rendered manifests and the values used. kubectl get secret -A -l owner=helm shows them all. Lose them and Helm forgets the release.
  3. 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 of helm 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 N is "newline + N spaces"; indent N is "just N spaces". Use nindent when starting a block, indent when continuing one. Read the rendered output with helm template whenever 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.
  • default is your friend. {{ .Values.image.tag | default .Chart.AppVersion }} lets users override the image tag while falling back to the chart's appVersion.
  • toYaml re-serialises a value. Use it to pass a whole sub-tree to the manifest (resources, nodeSelector, tolerations).
  • tpl re-renders a string. {{ tpl .Values.someTemplate . }} lets a user supply a Go-template snippet in values.yaml that'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:

  1. 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.
  2. 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 build reads it; helm install doesn't reach the network if charts/ is already populated.
  • Conditions and tags are values-driven on/off switches. condition: redis.enabled reads .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-creation is the default; hook-succeeded cleans up after success; hook-failed cleans up after failure; before-hook-creation,hook-succeeded is 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:

  1. Replace the boilerplate _helpers.tpl referenceshelm create names them after the chart but with bugs around hyphenation. Read the generated file and adapt; don't trust the labels block blindly.
  2. Pin every image tag. Default image.tag: "" falls back to Chart.appVersion. Bump appVersion on every container release; commit it.
  3. Provide values.schema.json for charts other people consume. JSON Schema gives users typed errors instead of "your YAML rendered to nonsense at line 42". helm install validates against it automatically.
  4. Don't generate secrets inside the chart. randAlphaNum 16 is tempting; it makes upgrades non-deterministic because every render rolls a new value. Generate secrets out-of-band, supply them as values.
  5. Mark sensitive outputs. Anything in NOTES.txt ends up in helm get notes and probably in logs. Don't print passwords.
  6. Treat CRDs carefully. Charts that ship CRDs in crds/ install them once and never upgrade them. For upgrades, the operator runs kubectl apply -f crds/ manually. Document this in the README.
  7. 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 --install per release from one declarative file instead of you running the commands in this reference card by hand.
  • The templates/*.yaml a 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.md and 02-networking.md) teach the shape and behavior of. Helm's job stops at rendering + applying them; what happens after kubectl 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 .tgz and an image are both content-addressed blobs in the same kind of registry, just different artifact types under one spec.