DevOps · Kubernetes

Helm Charts for Production — A Field Guide

How Helm's Go-template rendering, values overrides, chart dependencies, and release lifecycle actually work under the hood, and the debugging habits — helm template, dry-run, hooks — that save you from a bad upgrade.

John Kihiu12 min read

How Helm's Go-template rendering, values overrides, chart dependencies, and release lifecycle actually work under the hood, and the debugging habits — helm template, dry-run, hooks — that save you from a bad upgrade. Most Helm pain I've seen isn't Helm being broken, it's someone treating a chart as a black box instead of just rendering it and reading the output.

Templating is Go templates plus Sprig, nothing more exotic

A Helm chart's templates are plain Go `text/template` syntax with the Sprig function library layered on top for the things Go templates don't do natively — string manipulation, list operations, default values, date formatting. `{{ .Values.replicaCount }}` pulls from values.yaml, `{{ .Release.Name }}` and `{{ .Release.Namespace }}` come from the release context Helm injects, and helpers defined in `_helpers.tpl` get called with `{{ include "mychart.fullname" . }}`. There's no magic beyond that — if a template isn't rendering the way you expect, it's a Go template problem, and it's easier to debug by rendering it locally than by staring at the chart source trying to trace values by eye.

YAML · values.yaml
replicaCount: 2

image:
  repository: registry.example.com/checkout-api
  tag: "1.4.2"
  pullPolicy: IfNotPresent

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    memory: 256Mi

ingress:
  enabled: true
  hostname: checkout.example.com

postgresql:
  enabled: true
  auth:
    database: checkout

Overrides layer on top of the chart's default values.yaml in a defined order: `--set key=value` on the command line wins over `-f custom-values.yaml`, which wins over the chart's own values.yaml. `--set` is fine for a one-off like a tag bump; anything meant to persist — an environment's actual config — belongs in a values file that's committed and passed with `-f`, because `--set` history lives only in your shell and in `helm get values`, not in a diffable file.

Dependencies pull in subcharts, not magic

A chart declares dependencies in `Chart.yaml` under a `dependencies` block — name, version, and repository. Running `helm dependency update` resolves those against a `Chart.lock` and downloads the actual chart archives into a `charts/` subdirectory, which is what gets packaged and installed alongside your own templates. This is how a chart for your app ends up quietly installing PostgreSQL or Redis as a subchart: the parent chart's values.yaml has a `postgresql:` block that maps directly onto the dependency chart's own values, scoped under that key. It's ordinary chart composition, not a separate mechanism — worth checking `charts/` after a dependency update to confirm you got the version you expected, since a loose version range in Chart.yaml can silently pull a newer subchart than you tested against.

YAML · Chart.yaml
apiVersion: v2
name: checkout-api
version: 0.4.0
appVersion: "1.4.2"
dependencies:
  - name: postgresql
    version: "15.x.x"
    repository: "https://charts.bitnami.com/bitnami"
    condition: postgresql.enabled
helm dependency update vs. build

helm dependency update re-resolves against the repositories and rewrites Chart.lock — use it when you've changed the dependencies block. helm dependency build just re-downloads charts.tgz files to match an existing Chart.lock — use it in CI, where you want reproducible installs, not fresh resolution.

Release state lives in a Secret, in-cluster

Every `helm install` or `helm upgrade` writes a new release record as a Kubernetes Secret (by default) in the release's namespace, labeled with the release name and revision number. That's what makes `helm rollback` possible — it's not reapplying old YAML from your local disk, it's reading the previous revision's stored manifest straight out of that Secret and applying it. It also means `helm history ` and `helm get manifest --revision N` work against cluster state, not your working directory — if someone deletes those Secrets, Helm loses the ability to diff or roll back that release even though the actual workloads keep running untouched.

helm upgrade --install without --atomic can leave a release stuck

If an upgrade fails partway, the release can land in a failed state that blocks the next upgrade until you run helm rollback manually. --atomic makes Helm roll back automatically on a failed upgrade, restoring the prior working release without a manual step at 2am.

helm template vs. install --dry-run for debugging

helm template renders the chart to plain YAML entirely client-side — no cluster contact at all, which makes it the fastest way to check "does this render the way I expect" and to diff output between two values files. helm install --dry-run (or upgrade --dry-run) does the same rendering but also talks to the cluster to run server-side validation against the actual API server, which catches problems `helm template` can't see: an invalid field for your cluster's API version, a webhook admission controller rejecting the manifest, or a CRD that doesn't exist yet. I reach for `helm template` first for a quick look and `--dry-run` right before a real upgrade, when I want the closest thing to a real preflight check.

Shell
helm template checkout-api ./chart -f values-prod.yaml

helm upgrade checkout-api ./chart \
  -f values-prod.yaml \
  --dry-run --debug \
  --atomic --timeout 5m

Hooks run outside the normal apply order

Chart hooks — annotated with `helm.sh/hook: pre-install`, `post-upgrade`, `pre-delete`, and so on — are resources Helm applies at specific points in the release lifecycle, outside the normal manifest ordering. A `pre-install` hook commonly runs a Job that creates a database schema before the application Deployment starts; a `post-upgrade` hook might run a smoke test or a cache-warming Job. Hooks aren't tracked as part of the regular release the same way — by default they aren't deleted on `helm uninstall` unless you set a hook-deletion-policy — which trips people up when a one-off migration Job from months ago is still sitting in the namespace.

Wrapping up

Helm's moving parts are simpler than they get credit for: Go templates plus Sprig for rendering, a layered values system for overrides, Chart.yaml dependencies for composing subcharts, and release state stored as a Secret in-cluster that makes rollback possible. The habit that prevents most bad upgrades is cheap — render with `helm template` or dry-run before you apply anything for real, and use `--atomic` so a failed upgrade doesn't leave the release stuck for the next person.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.