Stop Guessing: Helm Done Right in Under 30 Lines
Italic sub-headline: Practical patterns for sane templates, upgrades, and CI you can trust.
Why We Still Reach for Helm in 2025
Helm is still our go-to when we want a repeatable, reviewable Kubernetes release that doesn’t devolve into a folder full of YAML confetti. We like Kustomize for layering, Operators for lifecycle, and GitOps tools for drift control, but Helm hits a sweet spot: it packages every resource, wires in configuration, handles upgrades, and gives us a release object with history and status. We write a chart once and ship it anywhere our clusters live. The command surface is small enough to keep in our heads on a Monday, and the behavior is predictable enough that Friday deploys aren’t a horror movie.
It helps that Helm keeps improving. We get schema validation for values, OCI registries for charts, and better dependency management. The happy path—create a chart, template it, lint it, push to a registry, install with values—is quick, and the pitfalls are well-known. Whenever we see folks struggling, it’s almost always the same themes: template sprawl, values chaos, brittle upgrades, and an absent CI safety net. We’ll tackle each of those directly.
When in doubt, we follow the official docs. They’re concise, opinionated where it matters, and updated frequently, including upgrade flags and packaging guidance. If you’ve been away since Helm 2, the move to Helm 3 removed Tiller and simplified security in a big way, so we’ve got one less thing to worry about. If you’re new, you’ll pick it up faster than you expect. Here’s our goal today: make Helm boring, reliable, and safer than clicking “Apply” on a random YAML gist. For the full reference, we lean on the Helm docs.
Chart Structure That Ages Well
Charts rot when the structure’s unclear, templates fight each other, and values leak across files. We keep charts tidy by sticking to predictable file names, splitting concerns, and using library charts sparingly and intentionally. Most importantly, we keep the minimum number of templates we can get away with. If we need a Deployment, Service, and a ConfigMap, we create exactly those three templates and no more. One resource per file keeps reviews straightforward and diffs readable. When we must generate multiple resources from a loop (for example, multiple ServiceMonitors), we isolate that behavior and comment it well.
Here’s a trimmed sample you can drop into a repo today:
myapp/
Chart.yaml
values.yaml
values.schema.json
templates/
deployment.yaml
service.yaml
configmap.yaml
_helpers.tpl
And the Chart.yaml stays crisp:
apiVersion: v2
name: myapp
description: A small web app
type: application
version: 0.2.0
appVersion: "1.4.3"
dependencies:
- name: redis
version: 17.11.3
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: redis.enabled
We keep helpers in _helpers.tpl
for labels, names, and common snippets. We centralize labels and selectors so our rollouts and selectors never drift. We version charts with SemVer and the app version separately. Each file is short enough to grok in one coffee. If we find ourselves inventing a DSL in Go templates, that’s our cue to simplify rather than getting clever. Future us will thank present us for resisting the cleverness tax.
Values Without Tears: Layering, Defaults, and Schema
Values grow messy when every team adds a field and nobody agrees on names, types, or required settings. We tame that by doing three things: sane defaults, explicit overlays, and schema validation. Defaults live in values.yaml. Environment or tenant overlays sit in separate files—values-prod.yaml, values-staging.yaml, customer-a.yaml—so we can compose them at install time. We don’t hardcode magic in templates; we put it in defaults so templates remain thin glue.
Helm supports JSON Schema validation, which saves us from null-pointer gymnastics in templates. We write a values.schema.json and fail fast if a key’s missing or a type is wrong. That means fewer conditional ladders and fewer tickets about a boolean that was a string.
Minimal example:
# values.yaml
replicaCount: 2
image:
repository: ghcr.io/example/myapp
tag: "1.4.3"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8080
resources: {}
env: []
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"replicaCount": { "type": "integer", "minimum": 1 },
"image": {
"type": "object",
"properties": {
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string", "minLength": 1 },
"pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
},
"required": ["repository", "tag"]
},
"service": {
"type": "object",
"properties": {
"type": { "type": "string" },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
},
"required": ["port"]
}
},
"required": ["replicaCount", "image", "service"]
}
Hook this up and Helm will validate on install/upgrade. It keeps our values honest and our templates simple. If you haven’t used it, the schema files section is short and worth a read.
Templating That Won’t Surprise Future Us
Helm templates are just Go templates with extra functions, which is both nice and a trap. We try to keep logic near zero: calculate names, render lists into YAML, and bail with clear errors when something’s missing. That’s it. The rest stays in values, validated by the schema. We avoid nesting includes five levels deep, and we use helpers for name formats, standard labels, and annotations so our resources look consistent across charts.
A few patterns pay off. We use required with friendly messages to stop bad installs early. We fold structured maps via toYaml and nindent to avoid whitespace disasters. We render any templated string fields via tpl so downstream users can put simple templates in values without hacking our chart. When we must provide defaults, default is fine, but we don’t default secrets; we fail and say what to set.
A small excerpt:
{{- define "myapp.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount | default 2 }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "myapp.name" . }}
template:
metadata:
labels:
{{- include "myapp.labels" . | nindent 8 }}
spec:
containers:
- name: app
image: "{{ required "image.repository required" .Values.image.repository }}:{{ required "image.tag required" .Values.image.tag }}"
env:
{{- toYaml .Values.env | nindent 12 }}
This kind of template is boring in the best way, and that’s exactly what we’re after.
Safe Upgrades: SemVer, CRDs, and Rollbacks
Upgrades cause pain when we change resource names, selectors, or CRDs without planning. We keep upgrades safe by treating chart versions and app versions separately, bumping chart major versions for breaking changes that affect manifests or defaults. If a change breaks an in-place upgrade (say, selector changes on a Deployment), we document it in the chart’s CHANGELOG and force an intentional decision rather than a surprise outage. We also wire Helm flags to make failures obvious and recoverable. We default to helm upgrade with –atomic to roll back on errors, –cleanup-on-fail to avoid half-created debris, and –history-max to keep our release history bounded. If we need no downtime, we pair this with a proper readiness gate, deterministic labels, and immutable image tags.
CRDs deserve special care because Helm treats them differently. They’re applied before templates and aren’t upgraded by Helm automatically. If our chart owns CRDs, we often split them into a separate “crds” chart that’s updated deliberately, or we use a post-install job for conversions after we’ve applied new versions. When we rely on third-party CRDs (think Ingress controllers or monitoring), we pin versions and upgrade those controllers first in a controlled way. The official Kubernetes docs for CRDs are a good baseline to reference when planning compatibility across versions; we keep this page handy: Kubernetes CRD guide.
For guardrails, we do a dry-run and diff in CI, then a canary upgrade in a throwaway environment using the same values. If our canary breaks, –atomic saves us from the blast radius, and we go fix the templates instead of playing detective in the cluster.
Supply Chain and Security for Charts
We’d rather not install mystery charts. Thankfully, Helm now speaks OCI, which makes pushing and pulling charts from standard registries simple. We store our charts in a private registry, tag them predictably, and require signatures and provenance. The flow is straightforward: package the chart, sign it, push to an OCI registry, and verify before install. We prefer Sigstore’s cosign for signing because it’s keyless-friendly and plays nicely with CI. Helm’s native provenance (.prov) and verify are useful as well, especially when you control the key distribution.
A typical loop: helm package .; cosign sign –identity … oci://registry.example.com/charts/myapp:0.2.0; helm push oci://…; and later helm pull and verify. For production clusters, we pin exact chart versions and avoid mutable tags. We also use immutable image tags inside values so rollbacks are deterministic. If you’ve not used Helm’s registry support yet, the docs are short and to the point: Helm OCI registries.
We also keep secrets out of charts. Values files in Git are fine for non-sensitive config, but we encrypt secrets via SOPS or external secrets controllers and wire those in as references. It keeps the chart portable and reduces the chance of “who committed the password” postmortems. For signing and verification patterns and tutorials, the Sigstore docs are practical and current: cosign overview. With signatures enforced at the registry and verified before install, we trust what we deploy, and auditors stop sending us nervous emails.
CI That Actually Catches Chart Bugs
A chart that only works on our laptop is a prank, not a release. We put charts through CI that lints, templates, installs into a throwaway cluster, runs smoke checks, and publishes on success. The tools are simple and fast. helm lint spots obvious mistakes. chart-testing (ct) runs lint, dependency builds, and installs charts into kind or a target cluster for real manifest checks. For unit-level assurance, helm-unittest runs template assertions. Tie those together with GitHub Actions or your CI of choice and we’ve covered most footguns.
A compact GitHub Actions workflow might look like this:
name: chart-ci
on:
pull_request:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: helm/kind-action@v1
- uses: azure/setup-helm@v4
- name: Lint
run: helm lint ./myapp
- name: CT
uses: helm/chart-testing-action@v2
with:
command: ct lint-and-install
- name: Template Diff
run: helm template myapp ./myapp -f values.yaml | tee /dev/null
We prefer failing fast: schema validation errors and lint issues block merges. For PRs that change values, we render diffs so reviewers see exactly what will hit the cluster. On main, we push charts to our registry on tag and attach signatures. The chart-testing tool is well-documented and battle-tested; here’s the repo if you’ve not tried it yet: helm/chart-testing. Once this pipeline’s in place, we spend less time on “works on my kube” comments and more on real features.
Debugging, Observability, and Boring Day-Two Ops
Even with the best structure, stuff goes sideways. We treat debugging like any other workflow: make it repeatable and dull. First step is visibility into what Helm believes about a release. helm status gives us a quick snapshot; helm get all shows rendered manifests and hooks; helm history tells us if an upgrade flapped. If a release is stuck, helm rollback to a known-good version is our safety net, paired with –atomic to avoid dangling resources. We also render locally with helm template and feed that into kubectl diff against the target cluster—no surprise edits, just surgical changes.
We standardize labels and annotations so metrics, logs, and dashboards auto-discover our app. Keeping app.kubernetes.io/* labels in helpers means Prometheus, ServiceMonitors, and log pipelines can latch on without bespoke rules per chart. We also wire probes and resource requests as first-class values. If it’s not tunable via values, we assume someone will fork the chart, and forks make upgrades harder. While we’re here, we’ll plug a practice that saves afternoons: include a short NOTES.txt with commands for port-forward, tailing logs, and checking rollout status. It’s tiny, but it guides new teammates without Slack archaeology.
When something’s truly weird, we use helm diff (plugin) to see what changed across versions and kube events to see why the API server said no. And when we can’t reproduce locally, we fire up kind, install the chart with the exact values, and compare. It’s not glamorous, but neither is paging at 3 a.m., so we’ll pick boring any day.