Primer · Kubernetes packaging

Seventy YAML files, and the change that has to touch ten of them

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.

Scope. Two services on Amazon EKS across a shared-services account, workload accounts, and clusters in more than one region. Behaviour and version facts checked against the Helm documentation and changelog in August 2026 — Helm 4.1.x. Manifests are illustrative; the flags, fields and precedence rules are not.

The estate, before any tooling

Two services: checkout calls payments. Both run in three environments, and production is not one cluster:

EnvironmentClustersAccountWhy it differs
devdev-use1workload-dev1 replica, no autoscaling, debug logging, latest image
stagingstage-use1workload-stage2 replicas, prod-shaped, pinned image, synthetic data
prodprod-use1
prod-euw1
prod-gcp
workload-prodautoscaled, 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.

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.

Now change one thing

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:

ChangeFiles touchedWhat goes wrong
Add a standard label to everything70You miss the NetworkPolicies, because they were written by someone else
Bump the image in prod only3Three regions, three edits, and one of them lands a day later
Add a securityContext10Applied to nine; the tenth fails admission in a month when the policy turns on
New environment (perf)+14Copy staging, forget to change the namespace, deploy into staging
New service (ledger)+35Copy checkout, inherit its probe path, spend an afternoon on it
One platform decision, ten edits, five applies A swimlane across three roles. A platform engineer makes one decision, which becomes ten separate file edits because every Deployment holds its own copy. One of the ten needs its label selector changed rather than pasted. The review sees a pull request of ten near-identical diffs, and the change is then applied to five clusters. Nine land; one is missed, and the drift is not visible until an incident. Platform engineer Review Five clusters one decision spread across AZs 1 checkout dev stage use1 euw1 gcp payments dev stage use1 euw1 gcp 10 files to edit this one is payments, so its selector has to say payments — paste checkout in and the constraint silently matches nothing 2 one pull request 10 files changed, 9 identical diffs a reviewer approves the shape, not the tenth diff 3 dev kubectl apply stage kubectl apply use1 kubectl apply euw1 kubectl apply gcp never applied Nothing failed today. The estate is now shaped four different ways, and staging has stopped predicting production.
The work multiplies before it reaches a cluster. One decision becomes ten edits, because ten files each hold their own copy of the same Deployment. The reviewer sees nine identical diffs and one that is not, which is exactly the diff a human stops reading by.

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.

The two things everyone tries first

1 · Substitute variables with sed or envsubst

One 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.

2 · Kustomize

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:

The same estate as a chart

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.

One template, instead of ten Deployments

# 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:

The same decision, one edit, five upgrades The same swimlane. The platform engineer edits one template, so the pull request contains a single diff that a reviewer can actually read. The chart is versioned, and the same artifact is then rolled to all five clusters by name. There is no tenth file to miss, because there is no tenth file. Platform engineer Review Five clusters one decision spread across AZs 1 templates/deployment.yaml one {{- with }} block, one time 1 file to edit the selector is defined once, in _helpers.tpl 2 one pull request 1 file changed · chart 0.4.0 a reviewer reads the actual change, once 3 dev stage use1 euw1 gcp helm upgrade --install checkout charts/service -f envs/<env>.yaml — same chart version, five times, nothing to miss.
The same decision, once. There is no tenth file to forget, because there is no tenth file. The chart carries a version, so what rolled to five clusters is one named artifact rather than five hopefully-identical applies.

And the deltas become the whole file

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.

Which value wins

Four sources, and the order is documented rather than emergent — “in order of specificity”, each overriding the one above:

#SourceIn this estate
1values.yaml in the chartThe shape every shop service shares
2a parent chart’s values.yaml, if this is a subchartUnused here
3a file passed with -fenvs/prod-use1.yaml
4an individual --setThe image tag CI just built
Four sources, one value, in order of specificity Four inputs stacked from least to most specific: the chart's own values file, a parent chart's values if this is a subchart, a file passed with -f, and an individual --set. Each is shown setting the same replicaCount key. The rendered manifest takes the last one that set it, so --set wins over the environment file, which wins over the chart default. values.yaml in the chart the shape every service shares 1 replicaCount: 2 overridden below a parent chart's values.yaml only applies to subcharts 2 (unused here) a file passed with -f envs/prod-use1.yaml 3 replicaCount: 6 overridden below an individual --set what the pipeline just decided 4 replicaCount: 8 this one survives more specific replicaCount: 8 in the rendered manifest Values merge, they do not replace. To remove a chart default rather than add to it, set the key to null. “override the value of the key to be null, in which case Helm will remove the key from the overridden values merge.”
The same key, set four times. Each source overrides the one above it, so the chart says what a service is, the environment file says how this one differs, and the pipeline says which build.
$ 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.”

See it before you ship it

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.

The half that is not about YAML

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.”

A release is numbered, so it has an inverse A time axis of one release. Each upgrade increments the revision and records the chart version and the application version it carried. Because the previous state is stored rather than inferred, returning to revision six is one command, and doing so creates revision eight rather than rewriting history. revision 5 service-0.3.2 · app 1.14.0 Aug 12 superseded revision 6 service-0.4.0 · app 1.14.1 Aug 18 superseded revision 7 service-0.4.0 · app 1.14.3 Aug 19 deployed helm rollback checkout 6 creates revision 8 — the history is appended to, never rewritten kubectl apply has no row in this table, and no arrow back.
The half Kustomize has no answer for. A revision is stored, not inferred, so going back is a command rather than an archaeology exercise.
$ 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.

What is current, as of August 2026

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 4What it means for this estate
--atomic--rollback-on-failureA breaking rename. Every pipeline using --atomic fails on the new binary
--force--force-replaceSame — grep your CI before upgrading, not after
Server-side applyThe 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 filesValues can be split across YAML documents — aimed squarely at per-environment configuration
kstatus integrationReal readiness tracking rather than guessing from object existence
Chart apiVersion: v2Still 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.

Where Helm is genuinely the wrong tool

Two sections of enthusiasm earn one of honesty.

The arithmetic, once more

Plain manifestsOne chart
Files describing the two services709 templates + 5 values files
Add a topology constraint everywhere10 edits1 edit
Onboard a third service+35 files+1 values file
“How does prod differ from staging?”read 14 filesread 2, or one diff
Roll back last night’s deployfind the commit, re-apply, hopehelm rollback
“What version is in prod?”inspect the clusterhelm history

The first row is the one people quote. The last three are the ones that matter at three in the morning.

References

Version 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.