Helm Chart for the Two Services
Lesson 10 · Cloud Infrastructure & DevOps · ~15 min read
1. From raw YAML to a chart
The L09 lab left us with 8 hand-edited YAML files sitting in sample-app/k8s/. Adding a staging environment would mean duplicating all 8 files with different replicas counts and image tags — and then keeping the two trees in sync forever. Every hotfix, every resource tweak, every probe adjustment has to land in both trees, and eventually it won’t. That’s not a discipline problem; it’s a tooling problem.
Helm exists for exactly this problem. A Helm chart is the shape of the application — what objects it needs, how they wire together, which fields are configurable. The values file is the size — how many replicas, which image tag, whether to include in-cluster Postgres. Same chart, three values files, three fully independent releases. When you fix a probe endpoint, you change it once in the chart template and every environment picks it up on the next helm upgrade.
Helm v3 stores release state as Kubernetes Secrets in the release namespace. There is no server-side component (Tiller was removed in v3); helm is a client binary that communicates directly with the cluster’s API server using whatever credentials kubectl would use. The learning curve is the template language; everything else is just a thin wrapper around the familiar kubectl apply mental model.
2. Charts, releases, repos
A chart is a directory with three mandatory parts: Chart.yaml (name, version, appVersion), values.yaml (the default values tree), and templates/ (Go-templated K8s manifests). Optionally it has values-X.yaml per-env overrides, a values.schema.json for input validation, a ci/ directory for chart-testing fixtures, and any number of sub-charts in charts/.
A release is a named, versioned installation of a chart into a cluster namespace. You create a release with helm install shortener ./helm. The same chart can be installed as multiple releases in the same cluster under different names or namespaces — a pattern used for multi-tenant setups or for running dev and stage in the same cluster. The release name is threaded through every template via .Release.Name, so objects from different releases never collide.
A repo is an HTTP-served collection of packaged .tgz charts indexed by a index.yaml. We will not publish our chart to a repo this lesson — the chart lives in-tree at sample-app/helm/ and is referenced by relative path. Publishing to a repo (OCI registry, GitHub Pages, Artifact Hub) is the right move for shared library charts; for an application chart in a mono-repo, in-tree is simpler and keeps chart and app code in the same review cycle.
Helm tracks releases via Secrets in the namespace, keyed by release name and revision number. helm list -n shortener reads those Secrets. helm history shortener lists every revision with its status, chart version, and app version. This is the raw material for rollback.
3. The template engine
Helm uses Go’s text/template package augmented with the Sprig function library. Every template file in templates/ is rendered against a context object that exposes four top-level keys:
.Values— the merged values tree. Helm starts withvalues.yaml, then overlays each-f file.yamlargument left to right (last wins on conflicts), then overlays any--setflags..Release— metadata about the release:.Release.Name,.Release.Namespace,.Release.Revision,.Release.Service(alwaysHelm)..Chart— fields fromChart.yaml:.Chart.Name,.Chart.Version,.Chart.AppVersion..Files— access to non-template files bundled in the chart (rarely needed for application charts).
Key idioms you will see throughout the shortener chart:
default — {{ .Values.api.replicas | default 2 }}. Always supply a default for values that a partial env file might omit. Without a default, a missing value renders as the string <no value>, which YAML accepts but Kubernetes almost certainly rejects.
include — {{ include "shortener.labels" . | nindent 4 }}. Calls a named template defined in _helpers.tpl and passes the current context (.). The nindent 4 pipe prepends a newline and indents every line by 4 spaces, which is necessary for injecting a multi-line block into the middle of a YAML structure.
toYaml — {{ toYaml .Values.api.resources | nindent 12 }}. Serialises an arbitrary map or list from the values tree back into YAML text. Used for resource requests/limits blocks, which have structured shape.
required — {{ required "image.repository is required" .Values.image.repository }}. Aborts rendering with a human-readable error if the value is empty. Use for fields that must be set by the operator — it turns a silent bad-render into a loud pre-flight failure.
if / range — {{- if .Values.api.hpa.enabled }} ... {{- end }}. The leading - trims whitespace before the action, preventing stray blank lines in the rendered YAML. range iterates over a list or map.
_helpers.tpl is the conventional home for {{ define }} blocks. Every template in the chart can include helpers from _helpers.tpl. Nothing outside _helpers.tpl should define named templates — it is a convention that keeps the namespace clean.
4. Values file design
Three opinionated rules that apply to every production chart:
1. Shape values like components, not like manifests. The top-level keys in values.yaml are api, worker, postgres, ingress, and secrets — not deployment, service, or configmap. The chart’s job is to map those logical components onto the right K8s resource types. If you ever refactor a Deployment into a StatefulSet, you change the template, not the values schema.
2. Default features to “off” for cross-cutting concerns. ingress.enabled: false in values.yaml means you must opt in. postgres.enabled: true because dev always needs in-cluster Postgres — stage and prod override to false when they use a managed database. secrets.inChart: true for dev local clusters; stage and prod set it to false and supply credentials via External Secrets Operator. Per-env files turn things on; the global defaults stay conservative.
3. Pin image tags in env files, not in values.yaml. values.yaml ships with the L09 baseline tag (0.4.0). Each env file overrides to its actual release. CI never relies on the default tag; that way a CI pipeline that forgets to pass a -f values-prod.yaml picks up the explicitly non-production default instead of silently deploying a wrong version to production.
values-dev.yaml is primarily for local kind iteration: 1 replica everywhere, in-cluster Postgres, secrets in the chart. values-stage.yaml and values-prod.yaml are templates the reader fills in for their cloud accounts.
5. Atomic upgrades and rollback
Default helm upgrade is not atomic. It fires the Kubernetes API calls and returns immediately. If a Deployment rolls out a bad image and pods never reach Ready, Helm exits 0 and you only discover the problem when the on-call fires. Use --atomic: if any resource fails to roll out within the --timeout window, Helm automatically rolls back to the previous revision and exits non-zero. Pair with --wait so the command does not return until all Deployments report Available.
The canonical production deploy command for this chart:
helm upgrade --install shortener ./helm \
-f values.yaml -f values-prod.yaml \
--atomic --wait --timeout 5m \
--namespace shortener --create-namespace
--install means “if no release named shortener exists yet, create it; otherwise upgrade it”. One command covers first deploy and all subsequent deploys. --create-namespace creates the namespace if it doesn’t exist, which is necessary on a fresh cluster.
helm history shortener prints every revision with its status (deployed, superseded, failed), the chart version, and the app version. helm rollback shortener 3 reverts to revision 3 and records the rollback as revision 4 — history is append-only. This is the rollback-mid-incident UX that raw kubectl does not provide without an external tracking mechanism.
6. Chart-testing in CI
The helm/chart-testing project (ct) bundles several phases that simple helm lint misses. ct lint parses every chart’s templates against values.yaml, validates Chart.yaml is well-formed, and runs helm lint. ct install installs the chart into a real cluster (provided by helm/kind-action in CI), waits for all pods to become Ready, and runs any helm.sh/hook: test jobs. A PR that breaks a template gets blocked before any human reviews it.
The GitHub Actions workflow that wires all this up:
name: Helm chart-testing
on:
pull_request:
paths: ["sample-app/helm/**"]
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- uses: azure/setup-helm@v4
with: {version: v3.14.0}
- uses: helm/chart-testing-action@v2
- uses: helm/kind-action@v1
- run: ct lint --chart-dirs sample-app --charts sample-app/helm
- run: ct install --chart-dirs sample-app --charts sample-app/helm
The fetch-depth: 0 is necessary because ct uses git history to detect which charts changed and skip unchanged ones. Without the full history, ct may report “no chart changes detected” and skip the install step silently.
7. Secrets handling in charts
secrets.inChart: true is dev-only
The shortener chart’s secret.yaml template is guarded by {{- if .Values.secrets.inChart }}. When inChart: true, the chart renders a Kubernetes Secret with stringData populated from .Values.secrets.databaseUrl and .Values.secrets.postgresPassword — plaintext values that anyone with read access to the values file or the Helm release Secret can retrieve. That is fine for a local kind cluster where the only viewer is you.
For staging and production, set secrets.inChart: false in the per-env values file. The chart skips creating the Secret entirely and assumes an external operator — External Secrets Operator pulling from AWS Secrets Manager, or Sealed Secrets, or SOPS-encrypted values — populates the Secret before Helm installs. The chart’s Deployments reference the same Secret name regardless of how it was created, so the swap is seamless. Module 5 covers External Secrets in depth; the chart already accommodates the transition.
8. Common pitfalls
Common pitfalls
{{ .Values.foo }}with no default, omitted in per-env file. Renders as<no value>. The cluster admits an invalid manifest and the pod spec is garbage. Fix: always use| default "fallback"orrequired "msg"in the template.includewithoutnindent. The named template output lands at column 0 regardless of its position in the parent YAML structure, causing a YAML parse error at install time — not at render time.helm lintcatches this; always run it beforehelm install.- Storing image tags in both
Chart.yaml appVersionandvalues.yaml image.tag. Pick one source of truth. This chart usesvalues.yaml;appVersionis informational only. - Forgetting
--create-namespaceon first install. Helm will fail with “namespace not found” if the namespace doesn’t exist and you haven’t pre-created it. Always pairhelm upgrade --installwith--create-namespace.
References
- Helm v3 Chart Template Guide: helm.sh/docs/chart_template_guide/
- Helm Best Practices: helm.sh/docs/chart_best_practices/
helm/chart-testing: github.com/helm/chart-testinghelm/kind-action: github.com/helm/kind-action- Sprig function docs: masterminds.github.io/sprig/
- Shortener chart:
cloud-devops/sample-app/helm/