← Back to homekubernetes

Helm, honestly: what it actually does for our Kubernetes deployments

← All writing

Most teams meet Helm the same way: someone hands them a kubectl apply -f k8s/ folder that has quietly grown to fourteen files, three of which are near-identical copies for dev, staging and production. Someone changes the resource limits in two of them and forgets the third. Production runs a different config to the one in the repo and nobody can say when that happened.

That is the problem Helm solves. Not "packaging" in the abstract — that specific, boring, recurring failure.

Helm is two tools wearing one coat

It helps enormously to separate the two jobs Helm does, because they fail in different ways.

1. It is a template engine. A chart is a directory of Go-templated YAML plus a values.yaml of defaults. helm template renders those templates into plain Kubernetes manifests. Nothing magical: string interpolation, loops, conditionals, and a few helper functions.

2. It is a release ledger. When you run helm upgrade --install, Helm stores the rendered manifests and the values used, as a Secret in the target namespace, versioned. That record is what makes helm rollback, helm diff and helm history possible. It is also what lets Helm work out that a Service you removed from the chart should be deleted from the cluster — something kubectl apply -f will never do for you.

The second job is the one people undervalue. Templating you can get from Kustomize, envsubst, or a Node script. A durable, in-cluster record of "this exact set of objects, with these exact values, is release 47 of this app" is harder to hand-roll.

What a chart looks like when we build one

We generally maintain one internal chart per application shape, not per application. A Next.js app, an API service and a worker have different needs; six Next.js apps do not.

charts/web-app/
  Chart.yaml
  values.yaml
  templates/
    _helpers.tpl
    deployment.yaml
    service.yaml
    ingress.yaml
    hpa.yaml
    NOTES.txt

Chart.yaml carries two versions that people conflate:

apiVersion: v2
name: web-app
version: 1.4.2        # the chart's own version — bump when templates change
appVersion: "2026.02.11"  # the default image tag of the app

A fragment of deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "web-app.fullname" . }}
  labels: {{- include "web-app.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          resources: {{- toYaml .Values.resources | nindent 12 }}
          envFrom:
            - configMapRef:
                name: {{ include "web-app.fullname" . }}-env
{{- if .Values.probes.enabled }}
          readinessProbe:
            httpGet: { path: /api/health, port: http }
            initialDelaySeconds: {{ .Values.probes.initialDelay }}
{{- end }}

And then the interesting part — environments become small, readable diffs rather than duplicated files:

# values/production.yaml
replicaCount: 4
image:
  repository: registry.example.lk/marketing-web
resources:
  requests: { cpu: 200m, memory: 256Mi }
  limits:   { memory: 512Mi }
ingress:
  host: www.example.lk
  tls: true
probes:
  enabled: true
  initialDelay: 10
# values/staging.yaml
replicaCount: 1
resources:
  requests: { cpu: 50m, memory: 128Mi }
ingress:
  host: staging.example.lk

Staging is now eleven lines instead of a 200-line copy of production that drifts. When a reviewer asks "how does staging differ from prod?", the answer is the file, not an archaeology session.

The procedure we actually follow

Illustration: a harbour pilot checking charts and tide gauge before pulling the lock lever, a safety line coiled behind him.

This is the loop, in order. It matters that the read-only steps come before the write step.

Scaffold and lint.

helm create charts/web-app          # then delete about half of what it generates
helm lint charts/web-app -f charts/web-app/values/production.yaml

Render locally and read it. Templating errors are cheap to find here and expensive to find in a cluster.

helm template marketing-web charts/web-app \
  -f charts/web-app/values/production.yaml \
  --set image.tag=sha-9f2c1ab | less

We pipe that into kubeconform in CI to catch schema mistakes against the cluster's actual API versions:

helm template ... | kubeconform -strict -kubernetes-version 1.31.0 -summary

Diff against what is running. The helm-diff plugin is non-negotiable on our machines. It is the difference between deploying and gambling.

helm plugin install https://github.com/databus23/helm-diff
helm diff upgrade marketing-web charts/web-app \
  -n production -f charts/web-app/values/production.yaml \
  --set image.tag=sha-9f2c1ab

Upgrade, atomically, with a wait.

helm upgrade --install marketing-web charts/web-app \
  -n production --create-namespace \
  -f charts/web-app/values/production.yaml \
  --set image.tag=sha-9f2c1ab \
  --atomic --timeout 5m

--atomic implies --wait: Helm blocks until the rollout reports ready, and if it doesn't within the timeout, it rolls back automatically. This single flag has saved us more incidents than any dashboard. Without it, helm upgrade returns success the moment the API server accepts the objects, which tells you nothing about whether your pods are crash-looping.

Inspect and, if needed, roll back.

helm history marketing-web -n production
helm rollback marketing-web 46 -n production --wait
CommandWhat it tells you
helm history <rel>Every revision, its chart version, appVersion and status
helm get values <rel>The values actually in effect right now
helm get manifest <rel>The rendered YAML currently owned by the release
helm diff upgrade ...What the next apply would change

That middle one — helm get values — settles a lot of arguments at 2am.

Charts as artefacts

Since Helm 3.8, chart registries are just OCI registries, which means charts live next to your container images with the same auth and the same retention policy:

helm package charts/web-app                     # -> web-app-1.4.2.tgz
helm push web-app-1.4.2.tgz oci://registry.example.lk/charts
helm upgrade --install marketing-web \
  oci://registry.example.lk/charts/web-app --version 1.4.2 -n production

We do this for charts shared across more than one repository. For a single application, keeping the chart in the app repo and deploying from the checkout is simpler and we don't pretend otherwise.

The other genuine win: third-party software. Installing ingress-nginx, cert-manager, external-secrets or kube-prometheus-stack via Helm gives you a maintained, upstream-tested set of manifests and a single values.yaml to record your deviations. Writing those by hand is a bad use of a week.

Where Helm is the wrong answer

We are not evangelists. Honest ledger of costs:

  • Go templating over YAML is genuinely unpleasant. Whitespace-sensitive text templating producing a whitespace-sensitive format is a design mistake we all live with. nindent, toYaml and careful {{- discipline mitigate it. Charts with heavy conditional logic become unreadable — if a template needs more than two nested ifs, we split the chart.
  • Secrets are not Helm's job. --set apiKey=... lands in the release Secret in plaintext and in your shell history. We use External Secrets Operator pulling from the cloud secret manager, and the chart only ever references a secret name.
  • CRD lifecycle is weak. Helm installs CRDs from crds/ but will not upgrade them. Plan CRD changes as a separate, deliberate step.
  • For simple overlays, Kustomize is less machinery. If all you need is "same manifests, different replica count and image tag", kustomize with two overlays is easier to read and needs no template language. We use Kustomize for cluster-level config and Helm for applications — and for the awkward middle ground, ArgoCD or Flux will render a Helm chart and then patch it with Kustomize, which is often exactly right.
  • Helm's ledger and GitOps overlap. If Argo or Flux is reconciling from Git, that is your source of truth and Helm's release history becomes secondary. Do not run helm upgrade by hand against a GitOps-managed release; you will fight the controller and lose.

The rule we settle on

Use Helm when the same application shape is deployed to more than one place, or when you are consuming software someone else maintains. Use it with --atomic, always diff before you upgrade, keep templates dumb and values expressive, and keep secrets out of it entirely.

The payoff isn't elegance. It is that six months from now, someone who has never seen the project can run three read-only commands and know exactly what is deployed and why.

Enjoyed the read? We build this stuff for clients too.

Start a project