Primer · Kubernetes packaging
Two services across three environments and five clusters, written the plain way. Then the same estate written once. This is the arithmetic that makes Helm obvious — and the parts of it that are still genuinely unpleasant.
Two services: checkout calls payments. Both run in three environments,
and production is not one cluster:
| Environment | Clusters | Account | Why it differs |
|---|---|---|---|
| dev | dev-use1 | workload-dev | 1 replica, no autoscaling, debug logging, latest image |
| staging | stage-use1 | workload-stage | 2 replicas, prod-shaped, pinned image, synthetic data |
| prod | prod-use1 prod-euw1 prod-gcp | workload-prod | autoscaled, PDBs, topology spread, real secrets, three regions |
That is five clusters. Each service needs the same set of objects in each of them. Not an exotic set — the boring one every service ends up with:
k8s/
checkout/
deployment.yaml
service.yaml
serviceaccount.yaml
configmap.yaml
hpa.yaml
poddisruptionbudget.yaml
networkpolicy.yaml
payments/
... the same seven
Seven objects × two services × five clusters.
The number
70
Seventy YAML files, to run two services. Before anyone has written a line of application code, added a third service, or opened a second region.
That number alone is not the problem. Seventy files that were each meaningfully different would be fine — that would just be a large system. The problem is what is actually inside them.
Here is checkout’s Deployment in staging.
# k8s/checkout/stage-use1/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout
namespace: shop
labels:
app.kubernetes.io/name: checkout
app.kubernetes.io/part-of: shop
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: checkout
template:
metadata:
labels:
app.kubernetes.io/name: checkout
spec:
serviceAccountName: checkout
containers:
- name: checkout
image: 111122223333.dkr.ecr.us-east-1.amazonaws.com/checkout:1.14.2
ports:
- containerPort: 8080
env:
- name: PAYMENTS_URL
value: http://payments.shop.svc.cluster.local:8080
- name: LOG_LEVEL
value: info
resources:
requests: { cpu: 100m, memory: 192Mi }
limits: { memory: 384Mi }
readinessProbe:
httpGet: { path: /healthz, port: 8080 }
And here is the same file for production in us-east-1. Read it looking for the
differences, not the content.
# k8s/checkout/prod-use1/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout
namespace: shop
labels:
app.kubernetes.io/name: checkout
app.kubernetes.io/part-of: shop
spec:
replicas: 6
selector:
matchLabels:
app.kubernetes.io/name: checkout
template:
metadata:
labels:
app.kubernetes.io/name: checkout
spec:
serviceAccountName: checkout
containers:
- name: checkout
image: 111122223333.dkr.ecr.us-east-1.amazonaws.com/checkout:1.14.1
ports:
- containerPort: 8080
env:
- name: PAYMENTS_URL
value: http://payments.shop.svc.cluster.local:8080
- name: LOG_LEVEL
value: warn
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { memory: 1Gi }
readinessProbe:
httpGet: { path: /healthz, port: 8080 }
Forty-one lines. Six of them differ.
Six values out of forty-one lines, and one of those six is an accident: production is on
1.14.1 while staging is on 1.14.2. Is that a deliberate hold, or did somebody
forget to promote? The file cannot tell you. Nothing in this layout distinguishes
“intentionally different” from “nobody got round to it”.
That is the first real cost, and it is not verbosity. It is that the deltas are invisible. The thing you care about — how does prod differ from staging — is buried in thirty-five lines of things that are identical and always will be.
A platform decision lands: every workload must spread across availability zones. It is four lines.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: checkout
Where does it go? Into every Deployment. That is ten files — two services × five
clusters — and note the last line: the selector has to name the right service in each one. Paste
it into payments with checkout still in the selector and you have written a
constraint that silently matches nothing.
Ten edits, one of which is not a copy—paste. And this is the easy kind of change. Consider the ones that follow:
| Change | Files touched | What goes wrong |
|---|---|---|
| Add a standard label to everything | 70 | You miss the NetworkPolicies, because they were written by someone else |
| Bump the image in prod only | 3 | Three regions, three edits, and one of them lands a day later |
Add a securityContext | 10 | Applied to nine; the tenth fails admission in a month when the policy turns on |
New environment (perf) | +14 | Copy staging, forget to change the namespace, deploy into staging |
New service (ledger) | +35 | Copy checkout, inherit its probe path, spend an afternoon on it |
The failure this actually causes
Not an outage on the day. The damage is drift: after six months of edits, no two environments are shaped the same, nobody can say how prod differs from staging, and staging stops predicting anything about production. You find out during an incident, when the thing that worked in staging does not work in prod, and the reason is a line somebody added to eight files out of ten.
sed or envsubstOne file, placeholders, a wrapper script. It works on the first afternoon and then it does this:
# deployment.tmpl.yaml
replicas: ${REPLICAS}
image: ${IMAGE}
env:
- name: LOG_LEVEL
value: ${LOG_LEVEL}
$ REPLICAS=6 IMAGE=... LOG_LEVEL=warn envsubst < deployment.tmpl.yaml | kubectl apply -f -
Then someone leaves LOG_LEVEL unset. envsubst does not complain — it
substitutes the empty string, and you apply:
- name: LOG_LEVEL
value:
Which is valid YAML. It is a null value where a string was expected, and the failure surfaces wherever the application parses it. The tool has no idea it just did something wrong, because it does not know it is producing YAML — it is doing string replacement on a text file. Give it a value containing a colon and you will find that out the hard way.
This one is a real answer, and it is worth being straight about that. Kustomize takes a base and applies overlays — no templating, no placeholders, just structured patches:
# overlays/prod-use1/kustomization.yaml
resources:
- ../../base
patches:
- path: replicas.yaml
target: { kind: Deployment, name: checkout }
The deltas become explicit and reviewable, which is exactly what the copy-paste layout lost. If your problem is only “the same manifests differ slightly per environment”, this may be all you need, and reaching past it for something heavier is not automatically the right call.
What it does not give you is the other half of the problem — the half that has nothing to do with YAML:
kubectl apply has no undo. Rolling back means finding the previous
commit and applying that, and hoping nothing was applied out of band in between.A chart is a directory with a required Chart.yaml, a values.yaml of
defaults, and a templates/ directory of Go templates. The documentation is specific about
the shape:
charts/service/
Chart.yaml # required
values.yaml # default configuration values
values.schema.json # optional JSON Schema for values
templates/
deployment.yaml
service.yaml
serviceaccount.yaml
configmap.yaml
hpa.yaml
poddisruptionbudget.yaml
networkpolicy.yaml
_helpers.tpl
NOTES.txt
# charts/service/Chart.yaml
apiVersion: v2 # required — v2 means "needs at least Helm 3"
name: service # required
version: 0.4.0 # required — the version of the CHART
appVersion: "1.14.2" # optional — the version of the app it installs
description: The standard shape for a shop service
apiVersion, name and version are the only required fields.
Note the two versions. version is the chart — the packaging. appVersion
is the software inside it. They move independently, and that separation is the first thing the
copy-paste layout could not express at all.
# charts/service/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "service.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "service.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "service.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ .Release.Name }}
{{- with .Values.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
ports:
- containerPort: {{ .Values.service.port }}
env:
{{- range $k, $v := .Values.env }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
readinessProbe:
httpGet:
path: {{ .Values.probePath }}
port: {{ .Values.service.port }}
Four things in there are worth pointing at, because they are the ones that make the difference:
.Release.Name and .Release.Namespace are built in. You do not pass
them; Helm supplies them from the command you ran. The same chart installed twice under different
release names produces two independent sets of objects.include "service.selectorLabels" is defined once in _helpers.tpl. The
selector bug from earlier — pasting checkout’s selector into
payments — is now structurally impossible, because there is one definition
and it derives from the release.{{- with .Values.topologySpreadConstraints }} means the block appears only when the
value is set. The four-line platform change is now one edit, in one file, and every environment
that wants it opts in.| quote and | toYaml exist because Helm knows it is emitting YAML.
The envsubst failure above cannot happen: value: {{ $v | quote }} produces
value: "", not a bare empty scalar.Here is the entire difference between production in us-east-1 and everything else.
Not a forty-one line file with six changed lines — six lines.
# envs/prod-use1.yaml
replicaCount: 6
image:
tag: "1.14.1" # deliberately held back — see CHANGE-4471
env:
LOG_LEVEL: warn
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { memory: 1Gi }
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
# envs/dev.yaml
replicaCount: 1
env:
LOG_LEVEL: debug
Dev is three lines, because dev differs from the default in exactly two ways.
This is the moment
The pinned image tag now has a comment and a ticket number next to it, because it is one of six lines in a file whose entire purpose is to say how prod is different. In the old layout that same fact was line 24 of 41, indistinguishable from the thirty-five lines nobody chose.
Helm did not just remove duplication. It made the deltas the artifact, and everything identical disappeared into a place where it can only be changed once.
Four sources, and the order is documented rather than emergent — “in order of specificity”, each overriding the one above:
| # | Source | In this estate |
|---|---|---|
| 1 | values.yaml in the chart | The shape every shop service shares |
| 2 | a parent chart’s values.yaml, if this is a subchart | Unused here |
| 3 | a file passed with -f | envs/prod-use1.yaml |
| 4 | an individual --set | The image tag CI just built |
$ helm upgrade --install checkout charts/service \
--namespace shop --create-namespace \
-f envs/prod-use1.yaml \
--set image.tag=1.14.3
--set wins over the file, the file wins over the chart defaults. Which is exactly the
shape you want: the chart says what a service is, the environment file says how this one differs, and
the pipeline says which build.
One sharp edge worth knowing before it bites: values merge, they do not replace. If the chart
default has a httpGet probe and your environment needs an exec probe, setting
exec gives you a probe with both, which is invalid. The documented escape is to delete the
default explicitly:
readinessProbe:
httpGet: null # removes the key from the merged values
exec:
command: [/bin/grpc_health_probe, -addr=:8080]
“override the value of the key to be null, in which case Helm will
remove the key from the overridden values merge.”
Templating that you cannot inspect is worse than no templating. You can always render locally, without a cluster and without installing anything:
$ helm template checkout charts/service -f envs/prod-use1.yaml | head -30
$ helm template checkout charts/service -f envs/prod-use1.yaml \
| kubectl apply --dry-run=server -f -
$ diff <(helm template checkout charts/service -f envs/staging.yaml) \
<(helm template checkout charts/service -f envs/prod-use1.yaml)
That last command answers “how does prod differ from staging” in one line — the question the seventy-file layout could not answer at all.
Everything so far is deduplication, and Kustomize does that too. This is the part that is structurally different, and it is the reason the comparison is not a tie.
A Helm install creates a release: a named, numbered, stored thing. The templates can see it
— Release.Revision is documented as “the revision number for this release. On
install, this is 1, and it is incremented with each upgrade and rollback.”
$ helm history checkout -n shop
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
5 Aug 12 09:14 superseded service-0.3.2 1.14.0 Upgrade complete
6 Aug 18 11:02 superseded service-0.4.0 1.14.1 Upgrade complete
7 Aug 19 08:41 deployed service-0.4.0 1.14.3 Upgrade complete
$ helm rollback checkout 6 -n shop
That is the thing kubectl apply has no answer for. Not “re-apply the old commit and
hope” — a stored previous state, and one command to return to it. And because
Release.IsInstall and Release.IsUpgrade are available to templates, a chart can
behave differently on first install than on upgrade — seed a database once, skip it thereafter.
This matters more than usual right now, because the version you are probably running is going out of support.
Helm 3 is past its bug-fix date
Helm 4.0.0 was released 12 November 2025, at KubeCon — the first new major version in six years. The published support window for Helm 3 is “Bug fixes until July 8th 2026. Security fixes until November 11th 2026.”
Bug-fix support ended six weeks ago. Security fixes stop in under three months. If you are on Helm 3 today, the migration is a dated piece of work, not a someday.
| Change in Helm 4 | What it means for this estate |
|---|---|
--atomic → --rollback-on-failure | A breaking rename. Every pipeline using --atomic fails on the new binary |
--force → --force-replace | Same — grep your CI before upgrading, not after |
| Server-side apply | The default for new releases. Upgrades keep whatever the release used before, and anything created by Helm 3 stays on client-side apply |
| Multi-document values files | Values can be split across YAML documents — aimed squarely at per-environment configuration |
| kstatus integration | Real readiness tracking rather than guessing from object existence |
Chart apiVersion: v2 | Still works unchanged. Your charts do not need rewriting |
The server-side apply behaviour is the subtle one. Upgrading the CLI does not migrate your existing releases — they keep the apply method they were created with. So a fleet upgraded from Helm 3 runs in a mixed state, and the change in behaviour arrives whenever a release is next recreated rather than when you upgrade. Worth knowing before you are debugging it.
Two sections of enthusiasm earn one of honesty.
nindent is everywhere, and why a misplaced {{- can
turn a valid chart into a parse error at install time. helm template is not optional
practice; it is how you find out.values.yaml has grown a
hundred keys and the templates are branching on all of them, you have written software with no tests
in a language nobody chose. A values.schema.json helps; splitting the chart helps more.| Plain manifests | One chart | |
|---|---|---|
| Files describing the two services | 70 | 9 templates + 5 values files |
| Add a topology constraint everywhere | 10 edits | 1 edit |
| Onboard a third service | +35 files | +1 values file |
| “How does prod differ from staging?” | read 14 files | read 2, or one diff |
| Roll back last night’s deploy | find the commit, re-apply, hope | helm rollback |
| “What version is in prod?” | inspect the cluster | helm history |
The first row is the one people quote. The last three are the ones that matter at three in the morning.
Chart.yaml fields, and apiVersion: v2nullRelease.Name, Release.Revision, Release.IsInstall and the restVersion facts, flag names, precedence rules and built-in object fields checked against these sources in August 2026 (Helm 4.1.x). The manifests, file counts and the estate itself are illustrative — they are shaped like a real one, but the numbers are chosen to make the arithmetic legible rather than reported from a specific cluster. Where this page recommends rather than reports — Kustomize versus Helm, when a chart has grown too clever — it says so.