Kustomize
Kustomize is configuration customization for Kubernetes with no templating language at all — it lives right inside kubectl, and every file you write is ordinary, valid, schema-checkable YAML. You keep one canonical set of manifests as a base, then declare small overlays that patch it per environment: six replicas and a real image tag in prod, one replica and debug logging in dev, nothing else different. It solves the specific mess that shows up the moment an app needs to run in more than one place — three folders of copy-pasted YAML that quietly stop matching each other — without asking you to learn Go templates or ship a package. It's built into every kubectl binary since 1.14, so if you've typed kubectl apply -k, you've already used it.
Say you draw one really good picture of a treehouse. Your friends each want a slightly different version — one with a red door, one with a rope ladder instead of stairs, one with a "PROD" sign on it — but you don't want to redraw the whole treehouse three times and risk getting the windows wrong twice. So instead you keep your one good drawing exactly as it is, and for each friend you lay a sheet of tracing paper on top with just their one small change drawn on it. Kustomize is the tracing paper. The original drawing never gets touched, each sheet only shows the one thing that's different, and holding a sheet up over the drawing gives you that friend's finished treehouse.
What Kustomize is, and the problem it solves
☺ Like you're 10: It keeps one copy of your YAML and lets you stick small "change just this bit" notes on top, one set of notes per environment.
Kustomize is maintained under kubernetes-sigs/kustomize and has shipped embedded inside kubectl since v1.14 — no separate install required to get started, though the standalone kustomize binary exists too and usually runs a newer version. Its whole design fits in one word from its own tagline: it is template-free. There is no {{ .Values.replicaCount }}, no rendering engine turning almost-YAML into real YAML, no step where a typo in a template produces something the API server rejects for reasons that don't point back at your mistake. Every input file is a complete, valid Kubernetes manifest — or close to it — and the one extra file Kustomize needs, kustomization.yaml, just says which files to load and what to change about them.
The problem it's solving is the one every team hits the moment an app runs anywhere besides a single cluster: the object model gives you one Deployment, one Service, one ConfigMap — but dev, staging, and prod need three copies of each that are 95% identical and 5% deliberately different. Copy-pasting three folders looks fine on day one. By the end of the quarter, prod has a security context dev never got, staging's liveness probe is stale, and nobody can say with confidence what the actual difference between any two environments is anymore, which is exactly the drift operating Kubernetes well is trying to prevent. Whatever fixes this has to keep the shared 95% living in exactly one place and make the 5% that's genuinely different easy to read at a glance.
Kustomize and Helm solve the same problem from opposite directions. Helm parameterizes before the YAML exists — the chart author decides in advance which knobs to expose in values.yaml, and anything they didn't expose is out of reach for anyone consuming the chart. Kustomize patches after the YAML exists — because a base is plain, complete manifests, an overlay can reach into any field of any object, whether or not anyone anticipated needing to change it. That single difference explains almost everything else that differs between them: why Kustomize has no release history of its own, why it can customize a chart it doesn't own, and why its failure messages talk about merges instead of templates.
Bases and overlays: the pattern that carries the whole tool
☺ Like you're 10: One folder holds the real drawing. One small folder per environment holds only the sticky notes for what's different there.
A base is a directory of ordinary manifests plus a kustomization.yaml that lists them. You could kubectl apply -f the base directly and it would work — that's the whole promise of template-free configuration, nothing in it is provisional or half-finished. An overlay is a second, much smaller directory whose own kustomization.yaml points back at the base as a resources entry and layers a handful of changes on top: a namespace, a name prefix, a replica count, a patch file or two. Promotion from staging to prod stops being "diff two nearly-identical folders and hope you spot everything" and becomes "diff one small overlay file" — reviewable by a human in well under a minute, which is exactly the property GitOps tooling like GitOps on Kubernetes depends on to make a pull request an honest description of what's about to change.
There's no controller running any of this and nothing installed into the cluster on Kustomize's own behalf. It defines exactly one API object that never gets applied anywhere — the Kustomization file kind itself (apiVersion: kustomize.config.k8s.io/v1beta1) — plus an optional Component kind (v1alpha1) for a reusable fragment of one. Kustomize is a pure function: directory in, YAML out, nothing left running afterward.
The kustomization.yaml fields you'll actually reach for
☺ Like you're 10: One small file lists what to pick up and what to change about it — running the build does the picking-up and the changing, in a fixed order every time.
A build runs in two phases, and the order matters for reasoning about what a given file will see. First, generators run — configMapGenerator and secretGenerator manufacture brand-new objects from literals and files. Then transformers run over everything, generated objects included, in a fixed sequence: patches first, then the naming and metadata transformers (namespace, namePrefix/nameSuffix, labels, commonAnnotations), then replicas and images, and finally a name-reference pass that walks the whole object graph and fixes up every place one object refers to another by name. That last pass is the quiet workhorse: rename a ConfigMap with namePrefix: prod- and it also rewrites the configMapKeyRef inside the Deployment consuming it — something a text-substitution templating engine has no way to do, because it has no idea the two files are related.
| Field | What it does | Worth knowing |
|---|---|---|
resources | Files, directories, or remote URLs to load — including another kustomization directory as a base | Replaced the old bases field, which still works but is deprecated |
namespace | Sets metadata.namespace on every namespaced object it loads | Also fixes up ServiceAccount references inside RoleBindings |
namePrefix / nameSuffix | Prepends or appends to every resource's name | Every reference to that name elsewhere is rewritten automatically |
labels | Adds labels; includeSelectors/includeTemplates control how far they reach | Prefer this over commonLabels — see the gotcha below |
commonLabels | Adds labels and writes them into selectors | Legacy field — the selector write-through is the trap |
images | Rewrites container images by name → newName / newTag / digest | The field CI usually bumps on every build |
replicas | Overrides a workload's replica count by name | Shorter than writing a whole patch for the common case |
patches | Strategic-merge or JSON 6902 patches, with an optional target selector | The modern, unified field — replaces the two below |
patchesStrategicMerge / patchesJson6902 | The older, split versions of patches | Deprecated; kustomize edit fix migrates them |
configMapGenerator / secretGenerator | Builds ConfigMaps/Secrets from literals, files, or env files | Appends a content hash to the generated name by default |
components | Pulls in a reusable, optional slice of configuration | kind: Component, v1alpha1 |
Put together, a base and a production overlay look like this — a Deployment, Service, and ServiceAccount in the base, and an overlay that namespaces everything, prefixes the names, scales it up, and patches in real resource limits:
# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: # ordinary manifests, each valid on its own
- deployment.yaml
- service.yaml
- serviceaccount.yaml
labels: # modern replacement for commonLabels
- pairs:
app.kubernetes.io/name: checkout
app.kubernetes.io/part-of: storefront
includeSelectors: false # do NOT write these into the Deployment's selector
images:
- name: checkout # matches spec.containers[].image "checkout:..."
newName: ghcr.io/acme/checkout
newTag: 1.4.2 # CI bumps this line, or uses `kustomize edit`# overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: storefront-prod
namePrefix: prod-
resources:
- ../../base # a relative path — or a Git URL pinned to a tag/SHA
replicas:
- name: checkout # the base name, BEFORE namePrefix is applied
count: 6
images:
- name: ghcr.io/acme/checkout
newTag: 1.4.2
patches:
- path: resources-patch.yaml # strategic merge; target inferred from the file itself
- target: # JSON 6902, inline, matched by an explicit selector
kind: Deployment
labelSelector: app.kubernetes.io/part-of=storefront
patch: |-
- op: replace
path: /spec/template/spec/containers/0/imagePullPolicy
value: IfNotPresentTwo patch dialects: strategic merge and JSON 6902
☺ Like you're 10: One patch style looks like the object with only the changed bits filled in. The other gives step-by-step surgical instructions instead.
A strategic merge patch is a partial Kubernetes object, written in the same shape as its target, with only the fields you want changed filled in. Kubernetes' merge-key rules do the rest — containers merge by name, ports merge by containerPort — so the patch file reads almost like documentation of the one thing that's different. It's the right default, and it's the one people find pleasant to review, because it looks exactly like the object it's touching, minus everything that isn't changing.
# overlays/prod/resources-patch.yaml — a strategic merge patch
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout # identifies the target — the pre-prefix name
spec:
template:
spec:
containers:
- name: checkout # the merge key for the containers list
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { memory: 512Mi }A JSON 6902 patch (RFC 6902) is a list of surgical operations instead — add, replace, remove, copy, move, test — each one addressed by a JSON Pointer path such as /spec/template/spec/containers/0/env/1. Reach for it when a strategic merge can't say what you mean: deleting an element outright, or writing to a precise index in a list. It's less readable at a glance but it's the only dialect that can remove something rather than just override it.
# a JSON 6902 patch, removing an env var and adding a toleration
- op: remove
path: /spec/template/spec/containers/0/env/1
- op: add
path: /spec/template/spec/tolerations
value:
- key: workload-class
operator: Equal
value: batch
effect: NoScheduleGenerators: configMapGenerator and secretGenerator
☺ Like you're 10: These build a whole ConfigMap or Secret from a short list of settings, and quietly rename it every time the settings change.
configMapGenerator and secretGenerator manufacture entire objects from literals, a .env file, or referenced files, rather than requiring you to hand-write the ConfigMap or Secret YAML yourself. By default they append a hash of the generated content to the object's name — checkout-config becomes something like checkout-config-9fh72tk4bd — and that hash is doing real work, not decoration.
# base/kustomization.yaml (excerpt)
configMapGenerator:
- name: checkout-config
literals:
- LOG_LEVEL=info
- FEATURE_GIFTWRAP=false
# → emits ConfigMap "checkout-config-9fh72tk4bd" (content-hash suffix)
secretGenerator:
- name: checkout-db
literals:
- DB_USER=checkout_svc
# → base64-encoded Secret; the VALUE still has to come from somewhere real, see belowBecause the object's name changes whenever its content changes, and the name-reference transformer rewrites every consumer's reference to match, a Deployment that reads that ConfigMap via envFrom or a mounted volume gets a genuinely new pod template the moment the config changes — which triggers a real rolling update instead of a mutated ConfigMap sitting there unread until some unrelated restart happens to pick it up. This is the single cleverest thing Kustomize does with almost no code, and it's the exact mechanism behind the well-known "restart on ConfigMap change" trick that Helm charts have to fake by hand with a checksum annotation.
secretGenerator base64-encodes your literals into a Secret object — and base64 is encoding, not encryption; anything you type into literals: or a referenced .env file sits in Git in trivially reversible form forever, including in every past commit. Generate the shape of a Secret with Kustomize if that's convenient, but source the actual values from something built for it — an External Secrets Operator, Sealed Secrets, or SOPS — covered alongside RBAC's own guardrails in RBAC & Admission Control and DevSecOps's deeper Kubernetes security deep dive.
Day-to-day commands
☺ Like you're 10: A handful of commands cover almost everything: show me the result, show me what would change, then actually do it.
The single most important habit is to render before you apply. kubectl kustomize prints the finished YAML to stdout without touching the cluster at all, which turns "I hope my patch did what I meant" into "I can read the exact lines it produced."
# --- render and inspect: nothing touches the cluster --- $ kubectl kustomize overlays/prod # print the built manifests $ kustomize build overlays/prod # same, via the standalone binary (often newer) $ kubectl kustomize overlays/prod | kubectl apply --dry-run=server -f - $ kubectl diff -k overlays/prod # what WOULD change in the live cluster # --- apply and remove --- $ kubectl apply -k overlays/prod # build, then apply $ kubectl delete -k overlays/prod # build, then delete those objects # --- edit the kustomization file from a script or CI, no manual YAML surgery --- $ cd overlays/prod $ kustomize edit set image ghcr.io/acme/checkout=ghcr.io/acme/checkout:1.4.3 $ kustomize edit set replicas checkout=8 $ kustomize edit set namespace storefront-prod $ kustomize edit add resource networkpolicy.yaml $ kustomize edit fix # migrate deprecated fields in place # --- worth wiring into CI --- $ diff <(kustomize build overlays/staging) <(kustomize build overlays/prod) $ kubectl version --client # check the EMBEDDED kustomize version
In production you'll rarely run kustomize build by hand at all. An Argo CD Application whose source.path contains a kustomization.yaml is detected and rendered automatically by the repo-server, and Flux ships a controller named for the tool — kustomize-controller, reconciling a Kustomization custom resource that is not the same thing as the kustomization.yaml file, a naming collision that trips up nearly everyone the first time. GitOps on Kubernetes walks through that reconciliation loop end to end.
On a throwaway kind or minikube cluster: make base/ with a two-replica nginx Deployment, a Service, and a kustomization.yaml. Run kubectl kustomize base and read every line. Add overlays/prod/ with namePrefix: prod-, replicas: [{name: nginx, count: 5}], and a configMapGenerator with one literal — then actually consume it from the container with envFrom: [{configMapRef: {name: <generator name>}}], or the name hash has nothing to rewrite and the whole trick has no one watching. Build it, note the hashed ConfigMap name, apply with kubectl apply -k overlays/prod. Change the literal, run kubectl diff -k overlays/prod, and watch it propose a brand-new ConfigMap plus a changed pod template — the rollout trigger, live. Then add commonLabels: {tier: web} to the base and re-apply, to meet the next section's gotcha in person before it costs you an afternoon in production.
Gotchas and failure modes
☺ Like you're 10: A few things bite almost everyone once: a name that keeps changing, labels that break a running app, and patches that quietly do nothing.
The ConfigMap name hash has consequences beyond triggering a rollout. Old hashed ConfigMaps are not garbage-collected by Kustomize itself, so they accumulate in the cluster unless something else prunes them — a GitOps reconciler with pruning enabled, typically. Setting disableNameSuffixHash: true gets rid of the accumulation but brings back the exact stale-config problem the hash exists to solve. And an object that references the ConfigMap somewhere Kustomize's name-reference transformer can't see — buried in an annotation, or inside a custom resource it has no schema for — won't get rewritten, and will quietly point at a name that no longer exists.
commonLabels and immutable selectors is the classic outage. commonLabels writes its labels into spec.selector.matchLabels as well as into pod templates — but a Deployment's selector is immutable once created, so adding a common label to an app that's already running fails on apply with field is immutable, which shows up as an Argo CD sync that fails forever or a Flux reconciliation stuck in the same loop. The fix going forward is the newer labels field with includeSelectors: false for anything cosmetic. If you're already stuck, the only way out is deleting and recreating the Deployment — with the downtime that implies — which is exactly the kind of self-inflicted incident anti-patterns & pitfalls catalogues.
A patch that matches nothing can fail silently. A strategic-merge patch naming a resource that doesn't exist fails loudly, with no matches for Id .... But a patches entry whose target uses a labelSelector or annotationSelector that matches zero objects is, in the general case, a silent no-op: the build succeeds, the manifest comes out unchanged, and nobody notices until an environment quietly behaves like the one before it in the promotion chain. A golden-file test in CI — render the overlay and diff it against a committed expected output — catches this before a human has to.
Version skew is real and worth naming. The kustomize embedded in kubectl lags the standalone binary, sometimes by more than a year, and Argo CD and Flux each bundle their own version too. A build that renders cleanly on your laptop can fail in a pipeline running an older embedded version that predates the field you used. Pin deliberately where it matters, and be conservative about reaching for a feature that only recently stabilized. Ordering bites in a related way: because transformers run in a fixed sequence, a patch targeting a resource by name sees the pre-prefix name — which is why the earlier prod overlay example targets checkout, not prod-checkout, even though namePrefix is set in the very same file.
"I lost a whole afternoon to a patch that 'worked' — the build succeeded, no errors, CI went green — and did absolutely nothing. I'd typo'd the label selector on the patch target, one character off from what the Deployment actually carried, so it matched zero objects and Kustomize had no reason to complain; a selector matching nothing isn't an error to it, it's just an empty set. Now I don't trust a patch until I've actually read the rendered output and found my change sitting in it with my own eyes — kubectl kustomize isn't an extra step, it's the only way to know the difference between 'applied' and 'silently skipped.'"
Kustomize vs. Helm: when to reach for which
☺ Like you're 10: Kustomize is for the stuff you own and change a lot. Helm is for packaging something up to hand to somebody else. Most real clusters end up using both.
The honest framing is rarely "which tool is better" — it's "what are you actually doing right now." Distributing software to strangers who need a stable, documented set of knobs is a packaging problem, and packages want Helm. Operating workloads that you own, across a handful of environments you control, is a customization problem, and customization wants Kustomize.
| Dimension | Kustomize | Helm |
|---|---|---|
| Mechanism | Merge/patch real, complete objects | Go templating over a values file |
| Learning curve | Low — it's just YAML you already know | Medium — a templating language, plus Sprig functions |
| Reach | Any field of any object, anticipated or not | Only what the chart author chose to parameterize |
| Packaging & distribution | None built in — a Git path, or a remote URL pinned to a ref | Versioned charts, chart repositories, OCI registries |
| Release history & rollback | None — Git is your history | helm history / helm rollback, tracked in-cluster |
| Install footprint | Built into kubectl | A separate binary |
| Best at | Your own apps, several environments | Third-party software and internal, published add-ons |
Most mature platforms use both at once, deliberately: a vendor ships a chart, and you need one change the chart author never exposed as a value. Kustomize's helmCharts field can inflate the chart during the build (with --enable-helm) and then apply your patches to the rendered output — or you run helm template in CI, commit the result, and treat it as a plain Kustomize base, which has the side benefit of making every future diff reviewable as ordinary YAML instead of a template render nobody can eyeball. Either way, the rule of thumb holds up well in practice: Helm for what you consume from someone else, Kustomize for what you customize yourself. Kustomize isn't called out by name on the official CKA or CKAD exam blueprints, but kubectl apply -k, bases, and patches show up naturally the moment a scenario needs the same app running across more than one namespace or environment — this course's own Workloads & Scheduling domain page is the place that pattern gets exercised directly, and Platform Engineering's own Kustomize page goes further into the GitOps-specific angle if that's the direction you're headed next.
Foxy: So Kustomize is Helm but without the annoying curly braces?
Benny the Beaver: Without the templating, which isn't quite the same thing. Helm fills in blanks before the YAML even exists. I patch YAML that already exists — so I can change a field nobody thought to expose as a value in the first place.
Nutty the Squirrel: And I can just read the result straight off. kubectl kustomize overlays/dev and it's right there, cataloged, no guessing what a template loop was going to spit out.
Gizmo: Ooh, easiest fix ever — I'll drop the database password right into a secretGenerator literal. Comes out base64-encoded! Practically a vault. 🤑
Timmy the Turtle: Base64 is encoding, Gizmo, not encryption — I decoded it in about four seconds while you were still talking. Reference the secret from somewhere real. Never the literal value.
Recon the Robot: While you two argue — someone also added commonLabels to the checkout base an hour ago. I've been trying to reconcile the selector change against a live Deployment ever since. It's immutable. I can't win this one, and I'm not going to stop trying just because it's unwinnable.
Benny: That one's on me — switching to labels with includeSelectors: false right now. Same labels everywhere that matters, selector left alone, Recon gets to stop banging its head against an immutable field.
1. What does "template-free" actually mean, and what can a Kustomize overlay patch that a Helm chart's values file cannot? 2. You change one literal in a configMapGenerator and the pods roll. Why does that happen? 3. You add commonLabels to an already-running Deployment and the apply fails with field is immutable. What went wrong, and what's the fix going forward? 4. Your patches entry targets resources with a labelSelector that happens to match nothing, and the build still succeeds with no error. Why, and how would you catch that in CI before it reaches prod? 5. Name the two commands that show you what would happen before anything touches the cluster. 6. In one sentence, when would you reach for Kustomize over Helm, and when the reverse?
Check your answers
- Every input is ordinary, valid Kubernetes YAML — there's no placeholder syntax and no rendering step, only a merge of complete objects. Because it patches real objects instead of filling in author-provided parameters, an overlay can change any field of any object, including ones a chart author never thought to expose as a value.
- The generator appends a content hash to the ConfigMap's name, so a changed literal produces a new name; the name-reference transformer rewrites the Deployment's reference to match, which changes the pod template and triggers a rolling update. It's deliberate — it guarantees a config change actually reaches running pods instead of sitting in an unread ConfigMap.
commonLabelswrites its labels intospec.selector.matchLabelsas well as into pod templates, and a Deployment's selector is immutable once created. Going forward, use thelabelsfield withincludeSelectors: falseinstead; the existing stuck object has to be deleted and recreated, with the downtime that implies.- A
targetselector matching zero objects is a silent no-op in Kustomize's general case — unlike a named strategic-merge patch, which errors loudly withno matches for Id. Catch it with a golden-file test: render the overlay in CI withkustomize buildand diff it against a committed expected output. kubectl kustomize <dir>(or the standalonekustomize build <dir>) prints the rendered manifests, andkubectl diff -k <dir>shows what would actually change in the live cluster. Neither one applies anything.- Reach for Kustomize when you own the workload and are customizing it across environments you control; reach for Helm when you're distributing a package — your own, or consuming someone else's — that needs a stable, documented, versioned set of knobs.